diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..649338e --- /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 0000000..649338e --- /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 0000000..02995dd --- /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 0000000..b0dcfff --- /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 0000000..8555c0e --- /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 0000000..48bdc1b --- /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 0000000..dc743db --- /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 new file mode 100644 index 0000000..6d7974f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# Repository agent instructions + +## 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5aa59fe --- /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 0000000..bc322b4 --- /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 2fa51df..a24d3db 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,10 @@ The CLI currently ships these commands: - `ichiloto new` for guided project creation with a quest-like interactive flow - `ichiloto edit` for opening an Ichiloto project in the terminal editor - `ichiloto play` for running a project's main entrypoint -- `ichiloto upgrade` for adding mandatory save metadata to projects created by older Console versions +- `ichiloto upgrade` for converting a project made for an older engine to the current project format - `ichiloto validate` for checking a project's content and save metadata - `ichiloto generate:figlet` for forging terminal title art, menu banners, and wordmarks -- `ichiloto generate:map` for complete Engine 0.5 map scaffolding +- `ichiloto generate:map` for complete Engine 0.5 map scaffolding, created with its kind (`--kind`, one of the project's tilesets; asked when omitted) - `ichiloto generate:actor` for lightweight actor scaffolding - `ichiloto battle` for playing a fight from the arena, or simulating it to balance it - `ichiloto renderer:install` for installing a verified renderer package into a project's Engine (no Rust or build tools required) @@ -101,15 +101,116 @@ Renderer implementation discovery is managed internally. The command-line interface selects the stable `terminal` or `gpui` identity; it does not accept an executable location. -This Console change communicates renderer launch intent only. GPUI rendering -also requires companion Engine support that consumes `ICHILOTO_RENDERER` and -resolves the registered implementation; selecting `gpui` does not provide that -runtime integration by itself. +The Engine consumes `ICHILOTO_RENDERER` and launches the registered platform +implementation. WSL uses the Linux renderer, not a Windows executable. + +Tooling can ask the opened project's Engine which renderers are installed +without starting a game, opening a window, updating packages or editing settings: + +```bash +ichiloto renderer:list --directory=/path/to/project --json +ichiloto play --renderer=auto --no-interaction +``` + +The `ichiloto.renderers/1` report contains `renderers` entries with `id`, +`displayName`, `available` and an unavailable `reason`, plus `preferredId`. +Explicit `auto` prefers resolver-validated installed GPUI, otherwise Terminal. +This checks the installation, not a successful native handshake. An explicit +`gpui` choice never silently becomes Terminal. The ordinary interactive choice +and noninteractive Terminal default above remain unchanged. An older project +Engine without discovery reports a required update instead of guessed results. + +### Editor Play Startup + +```bash +ichiloto play --start=new-game --renderer=auto --terminal-window --no-tmux --no-interaction +ichiloto play --start=title --renderer=terminal --terminal-window --no-tmux --no-interaction +``` + +`--start=new-game` skips splash/title and uses the same New Game loader as the +title menu, at the project's authored starting map and cell. `--start=title` +opens the normal title flow. Neither option edits settings or reads a saved +game at startup. These options alone do not isolate settings or saves; use the +launch context below for isolated Editor playtesting. Ordinary gameplay after startup can still save normally. The +explicit mode is passed as process-only `ICHILOTO_START_MODE`; it refuses an +older Engine or a conflicting `starting_scene` rather than ignoring the request. +Without an explicit mode, custom-scene and ordinary startup remain supported. + +`play --launch-context=''` accepts one JSON object of at most **65,536 +bytes**, including whitespace (not a filename). Console validates the transport, +then probes **the project's Engine** through its `vendor/autoload.php` using +`GameLaunchContext::fromArray`. Unknown or invalid context fields and an older +Engine without the launch-context API are refused before any game/window launch. +The Engine owns the schema: optional `mode`, `dataRoot`, `start`, `cinematicId` +and `battleTest`. A field/cinematic start requires `new-game`; battle intent +cannot accompany field/title intent. Gameplay preflight remains in the Engine. + +```bash +ichiloto play --launch-context='{"mode":"new-game","dataRoot":"/absolute/canonical/private-root","start":{"mapId":"region/field","x":2,"y":3}}' --renderer=auto --terminal-window --no-tmux --no-interaction +``` + +The caller creates and owns `dataRoot`. The project's `LaunchDataRoot` validates +that it is an existing, canonical, writable directory outside the project and +not its ancestor, initially containing only an optional regular +`player-settings.json` copy and safe launcher `logs/`. Console uses that same +validator and `validatePath` before preparing `logs/error.log`; game stderr, +PHP `error_log` and Terminal diagnostic mirroring all use this file. Without +`dataRoot`, the existing project `logs/error.log` location remains in use. +Console does not copy project data, alter settings/saves or remove the caller's +root. The Engine controls isolated runtime data and save policy. + +`--launch-context` cannot accompany `--start` or any inherited +`ICHILOTO_START_MODE`, even an empty value. Inherited `ICHILOTO_LAUNCH_CONTEXT` +is **no longer accepted by Console**: pass the option instead. Validated JSON +is forwarded unchanged as process-only `ICHILOTO_LAUNCH_CONTEXT` to the game, +not the Console, renderer preparation, terminal opener or tmux server. Direct, +owned Terminal and new tmux launches share this transport; a stale tmux-server +startup mode is removed for the game. A context request refuses an existing +project tmux session rather than attaching to an unrelated game; use `--no-tmux` +for an independent launch. Existing ordinary-session attachment is unchanged. + +`--terminal-window` applies only when the chosen renderer is Terminal, including +`auto` fallback. GPUI launches directly. Terminal gameplay receives its own +terminal input/output while the watched `play` process stays alive until the +game ends. Diagnostic log output and the child exit status reach the caller; +stopping the caller cancels its owned game/descendants and cleans private +scratch, without closing other terminals or quitting a shared Terminal app. +macOS uses a nonce-owned Terminal window, Linux uses X11/XWayland `xterm`, and +Windows uses a new console with local PowerShell/taskkill lifetime helpers. +Missing platform prerequisites produce a diagnostic, not a hidden fallback. +Native windows and Windows/Linux execution still require platform qualification. When `play` finds an existing project tmux session, it attaches to the game that is already running. A renderer option applies when a new game process is launched and cannot change the renderer of an existing session. +### Renderer source development + +For Engine source checkouts with `resources/renderers/development.json`, a new +graphical `ichiloto play` launch checks whether the declared renderer source has +changed. The check does not build anything. In an interactive terminal, an +available update offers Update now, Continue this launch, or Skip this version. +Continue offers the update again next time; Skip suppresses it until the source +fingerprint changes. Non-interactive launches report the update and continue. +Game PHP and artwork changes do not invalidate the renderer source fingerprint. + +Run `ichiloto renderer:update` (or `ichiloto renderer:update `) to +build an optimized release package and install it on request. Console verifies +the package before installation and preserves the previous installation if a +build or verification fails. A failed update check or requested update warns but +does not prevent `play` from attempting the selected renderer; the Engine may +still reject a missing or incompatible installed renderer at startup. It never +silently switches to terminal. + +This update path is limited to a non-vendored Engine checkout and its declared +renderer checkout. Composer packages, including `--prefer-source` installs inside +the project's `vendor` directory, do not search for source, run Cargo or download +packages. Terminal launches and tmux reattachments skip the check. Direct PHP +entrypoints use the installed renderer without an update check. + +`renderer:install` remains available for installing a previously built package. +Automatic delivery of published platform packages is not implemented yet. + ## Project Scaffolding `ichiloto new` creates a valid Ichiloto project structure, including: @@ -137,12 +238,47 @@ Every new project also gets a generated `assets/Graphics/System/title.txt`, so t ### Balancing a fight -`ichiloto battle` opens the arena so you can play a troop. Give it a run -count instead and it simulates the fight repeatedly and reports what the -fight *is*: +`ichiloto battle` is a battle test, as in RPG Maker: it opens the arena so you +can set up a party and play a troop. Every fight starts fresh from the setup, +with a new party holding 99 of each item and a new troop. In the arena, go down +from the troop list into the party to change each member's actor, level and +equipment. + +The party can also be set up on the command line, one `--member` per member +(up to four), as `Actor[:level][,Slot=item...]`; actors and items go by id or +name. Without `--member` it is the starting party. Anything the party cannot be +built from is refused, every problem named, before a battle starts: + +```bash +ichiloto battle --member "Kaelion:20,Weapon=Iron Sword" --member Liora:18 --troop "Great Wolf" +``` + +A member can also carry a test loadout, so a command, skill or summon can be +tried before the game makes it available: + +- `Commands=` replaces the member's command menu, by command id (`attack`, + `skill`, `magic`, `summon`, `item`, `guard`, `escape`) or the label the + project shows for it; +- `Skills=` grants abilities or spells from the project's skill catalogue, on + top of what the member already knows; +- `Summons=` grants summons by id. + +Each is a list separated by `|`. The grants exist only in the fresh test +party; they change no campaign progress. Costs, targets and the summon's own +rules still apply, and a summon the member cannot hold is refused like any +other problem. Loadouts are for playing a fight: `--runs` refuses them, because +its simulator has every battler attack and would never use them. ```bash -ichiloto battle --troop "Bat x 2" --runs 100 +ichiloto battle --member "Kaelion:20,Commands=attack|skill|summon|item,Summons=djin" --member "Liora:20,Skills=Burn I|Heal I" --troop "Loch Ness" +``` + +Choose the renderer as `ichiloto play` does, with `--renderer` or +`--gpui-renderer`. Give it a run count instead and it simulates the fight +repeatedly, with the same party, and reports what the fight *is*: + +```bash +ichiloto battle --member Kaelion:20 --troop "Bat x 2" --runs 100 ``` The report opens with the party as fought — each member's level, what each @@ -175,19 +311,39 @@ readable at forty columns. ### Upgrading an existing project -Projects created before versioned saves were introduced need a permanent -project id and `assets/Data/save-compatibility.php`. From the project root, run: +A project records its format version as `"format"` in `ichiloto.json`; a +project without one is format 0. The game and the editor refuse a project +whose format is not the engine's, and `ichiloto play` and `ichiloto validate` +stop before launching or reading it, printing the engine's explanation. From +the project root, run: ```bash ichiloto upgrade ichiloto validate ``` -The upgrader preserves metadata that already exists. When the id is missing it -uses a canonical Composer package name when available, otherwise it derives -`ichiloto/`. Preview the result with `--dry-run`, or choose the -identity explicitly with `--id=vendor/project`. Never change that id after save -files exist. +The upgrade needs no arguments. It first lists, one line per pending format, +what will change and how much, then asks before writing anything. It runs +every step from the project's format to the engine's, in order, and records +the new format after each one, so an interrupted upgrade continues where it +stopped. A project that is already current is left untouched. + +- `--dry-run` prints the same list and changes nothing. +- `--yes` (`-y`) upgrades without the prompt; without a terminal it is + required, so a script never converts a project by accident. +- In a Git working tree the upgrade refuses to run over uncommitted changes, + so it is one reviewable, reversible change. `--allow-dirty` overrides that. +- `--directory` names another project directory, and `--id=vendor/project` + chooses the save identity when format 1 has to add one. + +It ends by printing the follow-up items and writing them to +`ichiloto-upgrade-report.md` in the project. The chain has one step: + +1. **Format 1: save metadata.** Adds a permanent project id and + `assets/Data/save-compatibility.php`, preserving metadata that already + exists. A missing id comes from a canonical Composer package name when + there is one, otherwise `ichiloto/`. Never change that id + after save files exist. ## FIGlet Generation @@ -206,7 +362,6 @@ The curated `--style` options are tuned for Ichiloto's house look, while `--font - `ichiloto edit` and `ichiloto play` expect to be run inside a valid Ichiloto project directory containing `ichiloto.json`. - Both commands prefer `tmux` when it is available and the session is interactive, then fall back to direct launch when it is not. -- `ichiloto battle` is not a full battle runner yet; it is still a placeholder command. ## Architecture @@ -229,6 +384,12 @@ composer install Run `./bin/ichiloto list` as a quick smoke test after dependency changes. +The `validate --migrate-actor-ids` work on `develop` uses +`Ichiloto\Editor\Actors\ActorIdentityMigration`, which is not in Editor +0.5.1. Before a future Console release includes that command, release a matching +Editor version and verify Console's dependency constraint and a clean install +against it. No new release is implied by the local source checkout. + ### Sibling checkouts cascade automatically The published dependencies resolve remotely — `composer.json` declares no @@ -288,3 +449,9 @@ stays installable everywhere. - Engine repository: [github.com/ichiloto/engine](https://github.com/ichiloto/engine) - Website repository: [github.com/ichiloto/website-v2](https://github.com/ichiloto/website-v2) - Console issues: [github.com/ichiloto/console/issues](https://github.com/ichiloto/console/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/bin/ichiloto b/bin/ichiloto index f5900f8..82c169d 100755 --- a/bin/ichiloto +++ b/bin/ichiloto @@ -14,12 +14,15 @@ if (file_exists(__DIR__ . '/../vendor/autoload.php')) { use Ichiloto\Console\Commands\BattleCommand; use Ichiloto\Console\Commands\EditCommand; +use Ichiloto\Console\Commands\EditHostCommand; use Ichiloto\Console\Commands\GenerateActorCommand; use Ichiloto\Console\Commands\GenerateFigletCommand; use Ichiloto\Console\Commands\GenerateMapCommand; use Ichiloto\Console\Commands\NewCommand; use Ichiloto\Console\Commands\PlayCommand; use Ichiloto\Console\Commands\RendererInstallCommand; +use Ichiloto\Console\Commands\RendererListCommand; +use Ichiloto\Console\Commands\RendererUpdateCommand; use Ichiloto\Console\Commands\UpgradeCommand; use Ichiloto\Console\Commands\ValidateCommand; use Symfony\Component\Console\Application; @@ -36,10 +39,13 @@ function bootstrap(): int new PlayCommand(), new BattleCommand(), new EditCommand(), + new EditHostCommand(), new GenerateMapCommand(), new GenerateActorCommand(), new GenerateFigletCommand(), new RendererInstallCommand(), + new RendererListCommand(), + new RendererUpdateCommand(), new UpgradeCommand(), new ValidateCommand() ]); diff --git a/composer.json b/composer.json index 43005d7..d304a6d 100644 --- a/composer.json +++ b/composer.json @@ -2,12 +2,14 @@ "name": "ichiloto/console", "description": "A simple CLI tool for managing games made using the Ichiloto Game Engine.", "type": "library", + "minimum-stability": "dev", + "prefer-stable": true, "require": { "php": "^8.4.1", "symfony/console": "^8.0", "laravel/prompts": "^0.3.1", "league/climate": "^3.8", - "ichiloto/editor": "^0.5.1", + "ichiloto/editor": "dev-develop", "amasiye/figlet": "^1.2.1" }, "require-dev": { @@ -44,12 +46,25 @@ "@php tests/portable-composer-dependencies.php", "@php tests/local-sibling-autoload.php", "@php tests/renderer-selection.php", + "@php tests/renderer-availability.php", + "@php tests/play-startup.php", + "@php tests/play-launch-context.php", + "@php tests/terminal-session.php", "@php tests/renderer-install.php", + "@php tests/renderer-preparation.php", "@php tests/generate-map.php", + "@php tests/gui-editor-launch.php", "@php tests/new-project-save-compatibility.php", + "@php tests/generate-actor-identity.php", + "@php tests/project-startup-identity.php", + "@php tests/validate-interactive-migration.php", "@php tests/legacy-project-upgrade.php", + "@php tests/project-format-refusal.php", "@php tests/validate-working-directory.php", - "@php tests/battle-report.php" + "@php tests/battle-report.php", + "@php tests/battle-renderer.php", + "@php tests/battle-members.php", + "@php tests/battle-member-loadouts.php" ] } } diff --git a/composer.lock b/composer.lock index 05fcd72..31e2523 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d695e4a5b279c73ec0af165be40b71fe", + "content-hash": "959885ce2886e1d737978d3b294470c1", "packages": [ { "name": "amasiye/figlet", @@ -256,22 +256,22 @@ }, { "name": "ichiloto/editor", - "version": "0.5.1", + "version": "dev-develop", "source": { "type": "git", "url": "https://github.com/ichiloto/editor.git", - "reference": "708a41988010ce37ee9d3234bb3886093db409bc" + "reference": "3f35a2918738c9b3a64a96b1b9af37eb273f1b8f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ichiloto/editor/zipball/708a41988010ce37ee9d3234bb3886093db409bc", - "reference": "708a41988010ce37ee9d3234bb3886093db409bc", + "url": "https://api.github.com/repos/ichiloto/editor/zipball/3f35a2918738c9b3a64a96b1b9af37eb273f1b8f", + "reference": "3f35a2918738c9b3a64a96b1b9af37eb273f1b8f", "shasum": "" }, "require": { "atatusoft-ltd/termutil": "^1.1", "ext-mbstring": "*", - "ichiloto/engine": "^0.5", + "ichiloto/engine": "dev-develop", "php": "^8.4.1", "symfony/console": "^8.0" }, @@ -279,6 +279,7 @@ "pestphp/pest": "^5.1", "phpstan/phpstan": "^2.1" }, + "default-branch": true, "type": "library", "autoload": { "files": [ @@ -301,22 +302,22 @@ "description": "A simple TUI editor for the ichiloto game engine.", "support": { "issues": "https://github.com/ichiloto/editor/issues", - "source": "https://github.com/ichiloto/editor/tree/0.5.1" + "source": "https://github.com/ichiloto/editor/tree/develop" }, - "time": "2026-09-14T02:30:38+00:00" + "time": "2026-10-13T04:05:49+00:00" }, { "name": "ichiloto/engine", - "version": "0.5.0", + "version": "dev-develop", "source": { "type": "git", "url": "https://github.com/ichiloto/engine.git", - "reference": "6c0c3dfc177796b5af3d6951a0c85a321a3ba8ce" + "reference": "739da6638fe4808d6ed3209e9710bf4210493023" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ichiloto/engine/zipball/6c0c3dfc177796b5af3d6951a0c85a321a3ba8ce", - "reference": "6c0c3dfc177796b5af3d6951a0c85a321a3ba8ce", + "url": "https://api.github.com/repos/ichiloto/engine/zipball/739da6638fe4808d6ed3209e9710bf4210493023", + "reference": "739da6638fe4808d6ed3209e9710bf4210493023", "shasum": "" }, "require": { @@ -355,9 +356,9 @@ "description": "A simple game engine for making terminal based JRPGs, lovingly written in PHP.", "support": { "issues": "https://github.com/ichiloto/engine/issues", - "source": "https://github.com/ichiloto/engine/tree/0.5.0" + "source": "https://github.com/ichiloto/engine/tree/develop" }, - "time": "2026-08-21T17:13:04+00:00" + "time": "2026-10-13T03:29:37+00:00" }, { "name": "laravel/prompts", @@ -1547,9 +1548,11 @@ } ], "aliases": [], - "minimum-stability": "stable", - "stability-flags": {}, - "prefer-stable": false, + "minimum-stability": "dev", + "stability-flags": { + "ichiloto/editor": 20 + }, + "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.4.1" diff --git a/scripts/install-git-guards.sh b/scripts/install-git-guards.sh new file mode 100755 index 0000000..0dd0314 --- /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/Battle/BattleMemberOption.php b/src/Battle/BattleMemberOption.php new file mode 100644 index 0000000..13839f0 --- /dev/null +++ b/src/Battle/BattleMemberOption.php @@ -0,0 +1,100 @@ + $equipment Item references by slot name, null for an empty slot. + * @param list|null $commands Command references replacing the menu, or null to keep the normal commands. + * @param list $skills Skill references to grant, abilities or spells. + * @param list $summons Summon references to grant. + */ + private function __construct( + public string $actor, + public ?int $level, + public array $equipment, + public ?array $commands = null, + public array $skills = [], + public array $summons = [], + ) {} + + public static function parse(string $value): self + { + $parts = array_map(trim(...), explode(',', $value)); + [$actor, $level] = array_pad(array_map(trim(...), explode(':', array_shift($parts), 2)), 2, null); + if ($actor === '') { + throw new InvalidArgumentException(sprintf('--member "%s" needs an actor, as Actor[:level][,Slot=item...].', $value)); + } + if ($level !== null && preg_match('/\A[1-9][0-9]*\z/', $level) !== 1) { + throw new InvalidArgumentException(sprintf('--member "%s": the level must be a whole number from 1.', $value)); + } + $equipment = []; + $loadout = []; + foreach ($parts as $part) { + [$key, $reference] = array_pad(array_map(trim(...), explode('=', $part, 2)), 2, null); + if ($key === '' || $reference === null) { + throw new InvalidArgumentException(sprintf( + '--member "%s": equipment is given as Slot=item, such as Weapon=Iron Sword, and loadouts as Commands=, Skills= or Summons= with | between names.', + $value, + )); + } + $loadoutKey = strtolower($key); + if (in_array($loadoutKey, self::LOADOUT_KEYS, true)) { + if (isset($loadout[$loadoutKey])) { + throw new InvalidArgumentException(sprintf('--member "%s": %s is given more than once.', $value, $key)); + } + $loadout[$loadoutKey] = self::parseList($reference, $key, $value); + continue; + } + $equipment[$key] = $reference === '' ? null : $reference; + } + + return new self( + $actor, + $level === null ? null : (int) $level, + $equipment, + $loadout['commands'] ?? null, + $loadout['skills'] ?? [], + $loadout['summons'] ?? [], + ); + } + + /** @return list */ + private static function parseList(string $reference, string $key, string $value): array + { + $names = array_map(trim(...), explode('|', $reference)); + if (in_array('', $names, true)) { + throw new InvalidArgumentException(sprintf( + '--member "%s": %s needs one or more names separated by |, such as %s=%s.', + $value, + $key, + $key, + match (strtolower($key)) { 'commands' => 'attack|magic|summon', 'skills' => 'Burn I|Heal I', default => 'djin' }, + )); + } + $repeated = array_keys(array_filter(array_count_values($names), static fn(int $count): bool => $count > 1)); + if ($repeated !== []) { + throw new InvalidArgumentException(sprintf('--member "%s": %s names %s more than once.', $value, $key, implode(', ', $repeated))); + } + + return $names; + } +} diff --git a/src/Commands/BattleCommand.php b/src/Commands/BattleCommand.php index 9f20d58..ccc571e 100644 --- a/src/Commands/BattleCommand.php +++ b/src/Commands/BattleCommand.php @@ -2,12 +2,28 @@ namespace Ichiloto\Console\Commands; +use Ichiloto\Console\Battle\BattleMemberOption; use Ichiloto\Console\Battle\ParticipantSnapshot; +use Ichiloto\Console\Renderer\RendererRegistry; +use Ichiloto\Console\Renderer\RendererSelector; +use Ichiloto\Console\Support\GameLaunchCommandBuilder; +use Ichiloto\Console\Support\RendererUpdateOffer; +use Ichiloto\Console\Support\SourceRendererUpdateChecker; +use Ichiloto\Console\Support\SourceRendererUpdater; +use Ichiloto\Console\Support\TerminalInteractivity; +use InvalidArgumentException; +use Ichiloto\Engine\Battle\BattleCommandType; +use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; use Ichiloto\Engine\Battle\Resolution\CombatHitResult; use Ichiloto\Engine\Battle\Resolution\ElementalOutcome; use Ichiloto\Engine\Battle\Simulation\BattleSimulator; use Ichiloto\Engine\Core\Game; +use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneLibrary; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; use Ichiloto\Engine\Scenes\Arena\ArenaScene; +use Ichiloto\Engine\Scenes\Arena\BattleTestMember; +use Ichiloto\Engine\Scenes\Arena\BattleTestSetup; +use Ichiloto\Engine\Scenes\Arena\ProjectBattleTest; use Ichiloto\Engine\Battle\Simulation\SimulationReport; use Ichiloto\Engine\Entities\Character; use Ichiloto\Engine\Entities\EquipmentSlot; @@ -18,10 +34,12 @@ use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Config\ProjectConfig; +use Ichiloto\Engine\Util\Stores\ActorStore; use Ichiloto\Engine\Util\Stores\EnemyStore; use Ichiloto\Engine\Util\Stores\ItemStore; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -34,13 +52,49 @@ )] class BattleCommand extends Command { + private readonly RendererRegistry $rendererRegistry; + + private readonly RendererSelector $rendererSelector; + + private readonly TerminalInteractivity $terminalInteractivity; + + private readonly RendererUpdateOffer $rendererUpdateOffer; + + /** + * @param (callable(string, array): (int|string))|null $rendererUpdatePrompt + */ + public function __construct( + ?RendererRegistry $rendererRegistry = null, + ?RendererSelector $rendererSelector = null, + ?TerminalInteractivity $terminalInteractivity = null, + ?SourceRendererUpdateChecker $rendererUpdateChecker = null, + ?SourceRendererUpdater $rendererUpdater = null, + ?callable $rendererUpdatePrompt = null, + ) { + $this->rendererRegistry = $rendererRegistry ?? new RendererRegistry(); + $this->rendererSelector = $rendererSelector ?? new RendererSelector($this->rendererRegistry); + $this->terminalInteractivity = $terminalInteractivity ?? new TerminalInteractivity(); + $this->rendererUpdateOffer = new RendererUpdateOffer($rendererUpdateChecker, $rendererUpdater, + $this->terminalInteractivity, $rendererUpdatePrompt); + + parent::__construct(); + } + public function configure(): void { $this ->addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'The project directory.') ->addOption('troop', 't', InputOption::VALUE_REQUIRED, 'The troop to fight. Without it the arena opens on the list.') ->addOption('runs', 'r', InputOption::VALUE_REQUIRED, 'Simulate this many battles instead of playing one.') - ->addOption('turn-limit', 'l', InputOption::VALUE_REQUIRED, 'How long a simulated battle may run before it counts as a slog.', '50'); + ->addOption('turn-limit', 'l', InputOption::VALUE_REQUIRED, 'How long a simulated battle may run before it counts as a slog.', '50') + ->addOption('member', 'm', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + sprintf('A party member, as Actor[:level][,Slot=item...][,Commands=a|b][,Skills=a|b][,Summons=a|b]; repeat for each, up to %d. Without it, the starting party.', + class_exists(BattleTestSetup::class) ? BattleTestSetup::MAX_MEMBERS : 4)) + ->addOption('arena', 'a', InputOption::VALUE_REQUIRED, + 'The battle presentation\'s arena to fight in, by key, drawn by a graphical renderer. Without it, its default arena; the arena can also be chosen on the troop list.') + ->addOption('renderer', null, InputOption::VALUE_REQUIRED, + sprintf('Renderer to play the battle in (%s)', implode(', ', $this->rendererRegistry->ids()))) + ->addOption('gpui-renderer', null, InputOption::VALUE_NONE, 'Play the battle in the GPUI renderer.'); } public function execute(InputInterface $input, OutputInterface $output): int @@ -59,10 +113,27 @@ public function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - // Playing the fight is the point; simulating it is what you do once you - // have played it and want to know what it does a hundred times over. - if ($input->getOption('runs') === null) { - return $this->play($workingDirectory, $input->getOption('troop'), $output); + if (! class_exists(BattleTestSetup::class)) { + $output->writeln('This project\'s engine has no battle test setup; update its engine to use ichiloto battle.'); + + return Command::FAILURE; + } + + $playing = $input->getOption('runs') === null; + + // A simulation draws nothing, so a renderer is a mistake to point out. + if (! $playing && ($input->getOption('renderer') !== null || (bool) $input->getOption('gpui-renderer'))) { + $output->writeln('A renderer applies to playing a battle, not to simulating one with --runs.'); + + return Command::INVALID; + } + + $arena = $input->getOption('arena'); + + if ($arena !== null && ! $playing) { + $output->writeln('An arena applies to playing a battle, not to simulating one with --runs.'); + + return Command::INVALID; } $previousDirectory = getcwd(); @@ -75,10 +146,26 @@ public function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } + $party = null; + $troops = []; + try { $this->registerProjectStores(); - $party = $this->loadParty(); - $troops = $this->loadTroops($input->getOption('troop')); + // One setup for both: the party played with and the party simulated. + $setup = $this->createSetup((array) $input->getOption('member')); + if ($arena !== null) { + $setup = $this->chooseArena($setup, strval($arena)); + } + if (! $playing) { + $this->refuseUnsimulatedLoadouts($setup); + $party = $setup->createParty($this->getActorStore(), $this->getItemStore()); + $troops = $this->loadTroops($input->getOption('troop')); + } + } catch (InvalidArgumentException $invalid) { + $output->writeln('' . OutputFormatter::escape($invalid->getMessage()) . ''); + @chdir($previousDirectory ?: '.'); + + return Command::INVALID; } catch (Throwable $throwable) { $output->writeln('The project could not be read: ' . $throwable->getMessage() . ''); @chdir($previousDirectory ?: '.'); @@ -88,7 +175,38 @@ public function execute(InputInterface $input, OutputInterface $output): int @chdir($previousDirectory ?: '.'); - if ($troops === []) { + // Playing the fight is the point; simulating it is what you do once you + // have played it and want to know what it does a hundred times over. + if ($playing) { + $rendererOption = $input->getOption('renderer'); + if ($rendererOption !== null && ! is_string($rendererOption)) { + $output->writeln('The renderer option must be a renderer ID.'); + + return Command::INVALID; + } + try { + $renderer = $this->rendererSelector->resolve( + rendererOption: $rendererOption, + gpuiAlias: (bool) $input->getOption('gpui-renderer'), + canPrompt: $input->isInteractive() && $this->terminalInteractivity->supportsPrompts(), + ); + } catch (InvalidArgumentException $exception) { + $output->writeln($exception->getMessage()); + + return Command::INVALID; + } + // Only a graphical renderer draws an arena; the terminal draws the battle its own way. + if ($arena !== null && $renderer->id === 'terminal') { + $output->writeln('An arena is drawn by a graphical renderer; the terminal renderer draws none. Choose one with --renderer.'); + + return Command::INVALID; + } + $this->rendererUpdateOffer->offer($workingDirectory, $renderer->id, $input, $output); + + return $this->play($workingDirectory, $input->getOption('troop'), $renderer->id, $setup, $output); + } + + if ($troops === [] || ! $party instanceof Party) { $output->writeln('No troops to fight.'); return Command::FAILURE; @@ -521,7 +639,8 @@ protected function reportLimits(OutputInterface $output): void * @param OutputInterface $output Where to report a failure. * @return int The exit code. */ - protected function play(string $workingDirectory, ?string $troop, OutputInterface $output): int + protected function play(string $workingDirectory, ?string $troop, string $rendererId, BattleTestSetup $setup, + OutputInterface $output): int { $previousDirectory = getcwd(); @@ -531,28 +650,259 @@ protected function play(string $workingDirectory, ?string $troop, OutputInterfac return Command::FAILURE; } + // The engine reads the renderer the way `play` hands it to the game, + // from the environment, here for the arena alone. + $variable = GameLaunchCommandBuilder::RENDERER_ENVIRONMENT_VARIABLE; + $previousRenderer = getenv($variable); + putenv("{$variable}={$rendererId}"); + try { - // The arena opens on its list of troops; naming one skips straight to - // that fight. - new Game( - $this->projectName($workingDirectory), - options: [ - 'starting_scene' => ArenaScene::class, - 'arena_troop' => $troop ?? '', - ] - )->run(); + $this->runArena($this->projectName($workingDirectory), $troop ?? '', $setup); } catch (Throwable $throwable) { - @chdir($previousDirectory ?: '.'); $output->writeln('The arena could not start: ' . $throwable->getMessage() . ''); return Command::FAILURE; + } finally { + putenv($previousRenderer === false ? $variable : "{$variable}={$previousRenderer}"); + @chdir($previousDirectory ?: '.'); } - @chdir($previousDirectory ?: '.'); - return Command::SUCCESS; } + private function getActorStore(): ActorStore + { + $store = ConfigStore::get(ActorStore::class); + + return $store instanceof ActorStore ? $store : throw new \RuntimeException('The project\'s actors could not be loaded.'); + } + + private function getItemStore(): ItemStore + { + $store = ConfigStore::get(ItemStore::class); + + return $store instanceof ItemStore ? $store : throw new \RuntimeException('The project\'s items could not be loaded.'); + } + + /** + * Runs the arena with the setup, which opens on its list of troops; + * naming one skips straight to that fight. + */ + protected function runArena(string $projectName, string $troop, BattleTestSetup $setup): void + { + new Game($projectName, options: [ + 'starting_scene' => ArenaScene::class, + 'arena_troop' => $troop, + ArenaScene::SETUP_OPTION => $setup, + ])->run(); + } + + /** + * The setup fighting in one of the battle presentation's arenas, named by + * key. + * + * @throws InvalidArgumentException When the project has no such arena, or no battle presentation at all. + */ + protected function chooseArena(BattleTestSetup $setup, string $arena): BattleTestSetup + { + if (! method_exists($setup, 'withArena')) { + throw new InvalidArgumentException('This project\'s engine cannot choose a battle test arena; update its engine to use --arena.'); + } + + $catalog = BattlePresentationCatalog::load('assets'); + + if ($catalog === null) { + throw new InvalidArgumentException('--arena: the project declares no battle presentation, so it has no arenas.'); + } + + $choices = $catalog->getArenaChoices(); + + if (! array_key_exists($arena, $choices)) { + throw new InvalidArgumentException(sprintf('--arena: the project has no arena %s (its arenas: %s).', $arena, + implode(', ', array_map(static fn(string $key, string $name): string => sprintf('%s (%s)', $key, $name), array_keys($choices), $choices)))); + } + + return $setup->withArena($arena); + } + + /** + * The battle test setup the --member options describe, or without them the + * project's battle test party (its system data's battleTest, else the + * starting party). Either way the project's battle test arena applies + * unless --arena overrides it. Actors and items resolve by id or name; a + * member without a level takes its actor's authored level. + * + * @param list $members The --member values. + * @throws InvalidArgumentException Naming every problem, before any battle starts. + */ + protected function createSetup(array $members): BattleTestSetup + { + $actors = $this->getActorStore(); + $items = $this->getItemStore(); + // An older engine keeps no project battle test: the starting party it is. + $project = class_exists(ProjectBattleTest::class) ? ProjectBattleTest::loadFromProject() : null; + if ($members === []) { + return $project?->createSetup($actors) ?? BattleTestSetup::getFromStartingParty($actors); + } + if (count($members) > BattleTestSetup::MAX_MEMBERS) { + throw new InvalidArgumentException(sprintf('A battle test party has at most %d members; %d were given.', + BattleTestSetup::MAX_MEMBERS, count($members))); + } + + $problems = []; + $setupMembers = []; + foreach ($members as $value) { + try { + $option = BattleMemberOption::parse(strval($value)); + } catch (InvalidArgumentException $invalid) { + $problems[] = $invalid->getMessage(); + continue; + } + $actorId = $actors->canonicalId($option->actor); + if ($actorId === null) { + $problems[] = sprintf('--member %s: the project has no such actor (its actors: %s).', $option->actor, implode(', ', $actors->getActorIds())); + continue; + } + $equipment = []; + foreach ($option->equipment as $slot => $reference) { + $item = $reference === null ? null : $items->get($reference); + if ($reference !== null && $item === null) { + $problems[] = sprintf('--member %s: the project has no item %s.', $option->actor, $reference); + continue; + } + $equipment[$slot] = $item?->id; + } + $level = $option->level ?? $actors->require($actorId, 'ichiloto battle')->createCharacter()->level; + if ($option->commands === null && $option->skills === [] && $option->summons === []) { + $setupMembers[] = new BattleTestMember($actorId, $level, $equipment); + continue; + } + if (! method_exists(BattleTestMember::class, 'withCommands')) { + $problems[] = sprintf("--member %s: this project's engine cannot set a member's commands, skills or summons; update its engine.", $option->actor); + continue; + } + $known = count($problems); + $commands = $this->resolveCommands($option, $problems); + $skills = $this->resolveSkills($option, $problems); + $summons = $this->resolveSummons($option, $problems); + // A member whose loadout does not resolve is not built; its problems are all reported. + if (count($problems) === $known) { + $setupMembers[] = new BattleTestMember($actorId, $level, $equipment, $commands, $skills, $summons); + } + } + $setup = $setupMembers === [] ? null : new BattleTestSetup($setupMembers, $project?->setup?->arena ?? $project?->arena); + $problems = [...$problems, ...($setup?->getProblems($actors, $items) ?? [])]; + if ($problems !== [] || $setup === null) { + throw new InvalidArgumentException("The battle test party cannot be set up:\n" . implode("\n", $problems)); + } + + return $setup; + } + + /** + * Refuses a test loadout in a simulation, which would report numbers that + * never used it: the simulator has every battler attack, so commands, + * skills and summons change nothing there. + * + * @throws InvalidArgumentException When a member carries a loadout. + */ + protected function refuseUnsimulatedLoadouts(BattleTestSetup $setup): void + { + foreach ($setup->members as $member) { + if (($member->commands ?? null) !== null || ($member->skills ?? []) !== [] || ($member->summons ?? []) !== []) { + throw new InvalidArgumentException(sprintf( + '--member %s: --runs cannot use Commands, Skills or Summons. The simulator has every battler attack, so' + . ' its numbers would never exercise them. Leave out --runs to play the fight and use them; levels and' + . ' equipment still simulate.', + $member->actorId, + )); + } + } + } + + /** + * Resolves a member's Commands= list to the engine's command types, by id + * or by the label the project shows for it. + * + * @param list $problems Problems found, appended to. + * @return list|null The command menu, or null to keep the normal one. + */ + protected function resolveCommands(BattleMemberOption $option, array &$problems): ?array + { + if ($option->commands === null) { + return null; + } + $commands = []; + foreach ($option->commands as $reference) { + $command = BattleCommandType::fromCommandName($reference); + if ($command === null) { + $problems[] = sprintf('--member %s: there is no command %s (commands: %s).', $option->actor, $reference, + implode(', ', array_map(static fn(BattleCommandType $type): string => $type->value, BattleCommandType::cases()))); + continue; + } + $commands[] = $command; + } + + return $commands; + } + + /** + * Resolves a member's Skills= list against the project's skill catalogue, + * which spans its abilities and spells wherever they are authored. + * + * @param list $problems Problems found, appended to. + * @return list Canonical skill names. + */ + protected function resolveSkills(BattleMemberOption $option, array &$problems): array + { + $catalog = SkillCatalog::getProjectCatalog(); + $skills = []; + foreach ($option->skills as $reference) { + if ($catalog->findSkill($reference) !== null) { + $skills[] = $reference; + continue; + } + $similar = array_values(array_filter(array_keys($catalog->getSkills()), + static fn(string $name): bool => strcasecmp($name, $reference) === 0)); + $problems[] = sprintf('--member %s: the project has no skill %s%s.', $option->actor, $reference, + $similar === [] ? '' : sprintf(' (did you mean %s?)', implode(' or ', $similar))); + } + + return $skills; + } + + /** + * Resolves a member's Summons= list to the project's summon ids. + * + * @param list $problems Problems found, appended to. + * @return list Summon ids. + */ + protected function resolveSummons(BattleMemberOption $option, array &$problems): array + { + if ($option->summons === []) { + return []; + } + $library = new SummonCutsceneLibrary(); + $summons = []; + foreach ($option->summons as $reference) { + try { + $summon = $library->findById($reference); + } catch (\Throwable $unreadable) { + $problems[] = sprintf('--member %s: summon %s cannot be read: %s', $option->actor, $reference, $unreadable->getMessage()); + continue; + } + if ($summon === null) { + $ids = array_map(static fn($definition): string => $definition->id, $library->load()); + $problems[] = sprintf('--member %s: the project has no summon %s (summons: %s).', $option->actor, $reference, + $ids === [] ? 'none' : implode(', ', $ids)); + continue; + } + $summons[] = $summon->id; + } + + return $summons; + } + /** * Reads the project's name. * @@ -676,34 +1026,13 @@ protected function registerProjectStores(): void ConfigStore::put(ItemStore::class, new ItemStore()); } - if (! ConfigStore::has(EnemyStore::class)) { - ConfigStore::put(EnemyStore::class, new EnemyStore()); + if (! ConfigStore::has(ActorStore::class)) { + ConfigStore::put(ActorStore::class, new ActorStore()); } - } - /** - * Builds the project's starting party. - * - * @return Party The party. - */ - protected function loadParty(): Party - { - $system = asset('Data/system.php', true); - $members = []; - - foreach ((array) ($system['startingParty'] ?? []) as $member) { - $data = asset("Data/Actors/{$member}.php", true); - - if (is_array($data) && isset($data['data'])) { - $members[] = $data['data']; - } - } - - if ($members === []) { - throw new \RuntimeException('The project has no starting party.'); + if (! ConfigStore::has(EnemyStore::class)) { + ConfigStore::put(EnemyStore::class, new EnemyStore()); } - - return Party::fromArray($members); } /** diff --git a/src/Commands/EditCommand.php b/src/Commands/EditCommand.php index d172854..47f0672 100644 --- a/src/Commands/EditCommand.php +++ b/src/Commands/EditCommand.php @@ -4,6 +4,8 @@ namespace Ichiloto\Console\Commands; +use Ichiloto\Console\Support\EditorProjectBootstrap; +use Ichiloto\Console\Support\GuiEditorLocator; use Ichiloto\Editor\Editor; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -25,6 +27,7 @@ public function configure(): void { $this->addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'The project directory to open.'); $this->addOption('no-tmux', null, InputOption::VALUE_NONE, 'Launch directly without creating or reusing a tmux session.'); + $this->addOption('gui', null, InputOption::VALUE_NONE, 'Open the project in the graphical editor instead of the terminal editor.'); } /** @@ -39,13 +42,16 @@ public function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } + if ((bool) $input->getOption('gui')) { + return $this->launchGui($workingDirectory, $output); + } + if (! (bool) $input->getOption('no-tmux') && $this->shouldLaunchInTmux()) { return $this->launchInTmux($workingDirectory); } try { - $this->bootstrapEngineDependencies($workingDirectory); - $this->bootstrapProjectDependencies($workingDirectory); + EditorProjectBootstrap::prepare($workingDirectory); (new Editor($workingDirectory))->run(); } catch (Throwable $throwable) { $output->writeln($throwable->getMessage()); @@ -189,59 +195,30 @@ private function shouldShowTmuxStatus(string $workingDirectory): bool } /** - * Loads the engine so editor previews can resolve the engine types a - * project's data files reference. - * - * @param string $workingDirectory The project directory. - * - * @return void + * Opens the project in the graphical editor: the native window, with this + * console's `edit:host` as the session host it edits through. The + * terminal editor is untouched and stays available. */ - private function bootstrapEngineDependencies(string $workingDirectory): void + private function launchGui(string $workingDirectory, OutputInterface $output): int { - load_engine_autoloader($workingDirectory); - } + $locator = GuiEditorLocator::fromEnvironment(dirname(__DIR__, 2)); + $executable = $locator->locate(); - /** - * Registers the opened project's PSR-4 autoload rules for editor asset inspection. - * - * @param string $workingDirectory The project directory passed to the editor. - * @return void - */ - private function bootstrapProjectDependencies(string $workingDirectory): void - { - $composerPath = rtrim($workingDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'composer.json'; + if ($executable === null) { + $output->writeln($locator->describeMissing()); - if (! is_file($composerPath)) { - return; + return Command::FAILURE; } - $composer = json_decode((string) file_get_contents($composerPath), true); - $autoloadRules = $composer['autoload']['psr-4'] ?? []; + $project = realpath($workingDirectory) ?: $workingDirectory; + $command = [ + $executable, + '--project', $project, + '--', + PHP_BINARY, dirname(__DIR__, 2) . '/bin/ichiloto', 'edit:host', '-d', $project, + ]; + passthru(implode(' ', array_map('escapeshellarg', $command)), $exitCode); - if (! is_array($autoloadRules) || $autoloadRules === []) { - return; - } - - spl_autoload_register(static function (string $class) use ($workingDirectory, $autoloadRules): void { - foreach ($autoloadRules as $namespace => $paths) { - if (! is_string($namespace) || ! str_starts_with($class, $namespace)) { - continue; - } - - $relativeClass = str_replace('\\', DIRECTORY_SEPARATOR, substr($class, strlen($namespace))) . '.php'; - - foreach ((array) $paths as $path) { - $filename = rtrim($workingDirectory, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR - . trim((string) $path, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR - . $relativeClass; - - if (is_file($filename)) { - require_once $filename; - } - } - } - }); + return $exitCode === 0 ? Command::SUCCESS : Command::FAILURE; } } diff --git a/src/Commands/EditHostCommand.php b/src/Commands/EditHostCommand.php new file mode 100644 index 0000000..0accf4f --- /dev/null +++ b/src/Commands/EditHostCommand.php @@ -0,0 +1,47 @@ +addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'The project directory to serve.'); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $directory = $input->getOption('directory') ?? getcwd() ?: '.'; + + if (is_not_valid_working_dir($directory)) { + fwrite(STDERR, 'The working directory is not valid: ' . $directory . PHP_EOL); + + return Command::FAILURE; + } + + EditorProjectBootstrap::prepare($directory); + (new SessionHost(STDIN, STDOUT, STDERR))->run(); + + return Command::SUCCESS; + } +} diff --git a/src/Commands/GenerateActorCommand.php b/src/Commands/GenerateActorCommand.php index 1a49bab..f871985 100644 --- a/src/Commands/GenerateActorCommand.php +++ b/src/Commands/GenerateActorCommand.php @@ -43,6 +43,7 @@ public function execute(InputInterface $input, OutputInterface $output): int 'class' => Character::class, 'data' => [ 'name' => $exportedName, + 'id' => $exportedName, 'description' => $exportedDescription, 'level' => 1, 'currentExp' => 0, diff --git a/src/Commands/GenerateMapCommand.php b/src/Commands/GenerateMapCommand.php index bb099ea..acb2d71 100644 --- a/src/Commands/GenerateMapCommand.php +++ b/src/Commands/GenerateMapCommand.php @@ -4,6 +4,7 @@ use Ichiloto\Console\Support\MapScaffolder; use Ichiloto\Console\Util\Path; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -11,6 +12,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use function Laravel\Prompts\select; use function Laravel\Prompts\text; use function Laravel\Prompts\textarea; @@ -25,6 +27,7 @@ public function configure(): void $this ->addArgument('name', InputArgument::REQUIRED, 'The name of the map.') ->addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'The maps root directory. Defaults to /assets/Maps.') + ->addOption('kind', null, InputOption::VALUE_REQUIRED, "The map's kind: one of the project's tilesets in assets/Data/Tilesets, by id.") ->addOption('region', null, InputOption::VALUE_REQUIRED, 'The map region.') ->addOption('description', null, InputOption::VALUE_REQUIRED, 'The map description.') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Overwrite all files for an existing map.'); @@ -45,6 +48,29 @@ public function execute(InputInterface $input, OutputInterface $output): int $region = $input->getOption('region'); $description = $input->getOption('description'); + // A map is born with its kind, the tileset its tiles and pieces come + // from. A project without tilesets has none to give it. + $kinds = $this->loadKinds(dirname((string) $mapsRoot)); + $kind = $input->getOption('kind'); + + if (is_string($kind) && ! isset($kinds[$kind])) { + $output->writeln($kinds === [] + ? "The project has no tilesets in assets/" . Tileset::DIRECTORY . ", so a map cannot be of kind {$kind}." + : "{$kind} is not one of the project's kinds: " . implode(', ', array_keys($kinds)) . '.'); + + return Command::FAILURE; + } + + if (! is_string($kind) && $kinds !== []) { + if (! $input->isInteractive()) { + $output->writeln("Name the map's kind with --kind: " . implode(', ', array_keys($kinds)) . '.'); + + return Command::FAILURE; + } + + $kind = (string) select('Choose the kind of map:', $kinds); + } + if (! is_string($region)) { $region = $input->isInteractive() ? text('Enter the region of the map:') : ''; } @@ -59,6 +85,7 @@ public function execute(InputInterface $input, OutputInterface $output): int [ 'name' => $displayName, 'region' => $region, + ...(is_string($kind) ? ['tileset' => $kind] : []), 'description' => $description, 'triggers' => [], 'events' => [], @@ -72,6 +99,9 @@ public function execute(InputInterface $input, OutputInterface $output): int } $output->writeln("Created map: {$mapDirectory}"); + $output->writeln(is_string($kind) + ? "Kind: {$kinds[$kind]}" + : 'Kind: none, since the project has no tilesets in assets/' . Tileset::DIRECTORY); foreach ($paths as $path) { $output->writeln(" {$path}", OutputInterface::VERBOSITY_VERBOSE); @@ -79,4 +109,27 @@ public function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + + /** + * The name of every tileset in the asset root that loads, by id. One that + * cannot load is not offered; `ichiloto validate` reports it. + * + * @return array + */ + private function loadKinds(string $assetRoot): array + { + $kinds = []; + + foreach (glob(Path::join($assetRoot, Tileset::DIRECTORY, '*.php')) ?: [] as $file) { + $id = basename($file, '.php'); + + try { + $kinds[$id] = Tileset::load($assetRoot, $id)->name; + } catch (\Throwable) { + continue; + } + } + + return $kinds; + } } diff --git a/src/Commands/PlayCommand.php b/src/Commands/PlayCommand.php index 1b9b915..f71e7e4 100644 --- a/src/Commands/PlayCommand.php +++ b/src/Commands/PlayCommand.php @@ -7,9 +7,16 @@ use Ichiloto\Console\AppConfig; use Ichiloto\Console\Renderer\RendererRegistry; use Ichiloto\Console\Renderer\RendererSelector; +use Ichiloto\Console\Renderer\ProjectRendererAvailability; use Ichiloto\Console\Support\GameLaunchCommandBuilder; use Ichiloto\Console\Support\GameProcessLauncher; +use Ichiloto\Console\Support\ProjectFormatCheck; +use Ichiloto\Console\Support\ProjectStartupCheck; +use Ichiloto\Console\Support\RendererUpdateOffer; use Ichiloto\Console\Support\TerminalInteractivity; +use Ichiloto\Console\Support\SourceRendererUpdateChecker; +use Ichiloto\Console\Support\SourceRendererUpdater; +use Ichiloto\Console\Support\TerminalSessionLauncher; use Ichiloto\Console\Util\Path; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; @@ -17,6 +24,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Formatter\OutputFormatter; #[AsCommand( name: 'play', @@ -34,18 +42,28 @@ class PlayCommand extends Command private readonly GameProcessLauncher $gameProcessLauncher; + private readonly RendererUpdateOffer $rendererUpdateOffer; + public function __construct( ?RendererRegistry $rendererRegistry = null, ?RendererSelector $rendererSelector = null, ?TerminalInteractivity $terminalInteractivity = null, ?GameLaunchCommandBuilder $launchCommandBuilder = null, ?GameProcessLauncher $gameProcessLauncher = null, + ?SourceRendererUpdateChecker $rendererUpdateChecker = null, + ?SourceRendererUpdater $rendererUpdater = null, + ?callable $rendererUpdatePrompt = null, + private readonly ProjectRendererAvailability $rendererAvailability = new ProjectRendererAvailability(), + private readonly ProjectStartupCheck $startupCheck = new ProjectStartupCheck(), + private readonly TerminalSessionLauncher $terminalSession = new TerminalSessionLauncher(), ) { $this->rendererRegistry = $rendererRegistry ?? new RendererRegistry(); $this->rendererSelector = $rendererSelector ?? new RendererSelector($this->rendererRegistry); $this->terminalInteractivity = $terminalInteractivity ?? new TerminalInteractivity(); $this->launchCommandBuilder = $launchCommandBuilder ?? new GameLaunchCommandBuilder(); $this->gameProcessLauncher = $gameProcessLauncher ?? new GameProcessLauncher($this->launchCommandBuilder); + $this->rendererUpdateOffer = new RendererUpdateOffer($rendererUpdateChecker, $rendererUpdater, + $this->terminalInteractivity, $rendererUpdatePrompt); parent::__construct(); } @@ -57,11 +75,14 @@ public function configure(): void { $this->addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'The directory to save the game data.'); $this->addOption('no-tmux', null, InputOption::VALUE_NONE, 'Launch directly without creating or reusing a tmux session.'); + $this->addOption('start', null, InputOption::VALUE_REQUIRED, 'Start at the title or directly in a new game (title, new-game).'); + $this->addOption('launch-context', null, InputOption::VALUE_REQUIRED, 'Engine launch context as a JSON object (at most 65536 bytes; cannot be combined with --start).'); + $this->addOption('terminal-window', null, InputOption::VALUE_NONE, 'Open an owned terminal window if the selected renderer is Terminal.'); $this->addOption( 'renderer', null, InputOption::VALUE_REQUIRED, - sprintf('Renderer to use (%s)', implode(', ', $this->rendererRegistry->ids())), + sprintf('Renderer to use (%s, auto prefers installed GPUI)', implode(', ', $this->rendererRegistry->ids())), ); $this->addOption('gpui-renderer', null, InputOption::VALUE_NONE, 'Use the GPUI renderer.'); } @@ -85,6 +106,15 @@ public function execute(InputInterface $input, OutputInterface $output): int } $workingDirectory = $resolvedWorkingDirectory; + $formatProblem = ProjectFormatCheck::getProblem($workingDirectory); + + if ($formatProblem !== null) { + // The engine would refuse the project after launch; say why before starting it. + $output->writeln(' ! ' . OutputFormatter::escape($formatProblem)); + + return Command::FAILURE; + } + $output->writeln('Playing the game in the working directory: ' . $workingDirectory, OutputInterface::VERBOSITY_VERBOSE); $config = new AppConfig($input, $output, $workingDirectory); $configuredMainFile = $config->get('main'); @@ -116,27 +146,67 @@ public function execute(InputInterface $input, OutputInterface $output): int } try { + $startMode = $input->getOption('start'); + if ($startMode !== null && !is_string($startMode)) { + throw new InvalidArgumentException('The start option must be title or new-game.'); + } + $launchContext = $input->getOption('launch-context'); + if ($launchContext !== null && !is_string($launchContext)) { + throw new InvalidArgumentException('The launch-context option must be a JSON object.'); + } + if (getenv(GameLaunchCommandBuilder::LAUNCH_CONTEXT_ENVIRONMENT_VARIABLE) !== false) { + throw new InvalidArgumentException('Pass launch intent with --launch-context, not inherited ICHILOTO_LAUNCH_CONTEXT.'); + } + $contextErrorLogFile = null; + if ($launchContext !== null) { + $this->launchCommandBuilder->rejectLaunchContextModeConflict($startMode); + $contextErrorLogFile = $this->startupCheck->prepareLaunchContext($workingDirectory, $launchContext); + } + if ($startMode !== null) { + $this->launchCommandBuilder->validateStartMode($startMode); + $this->startupCheck->requireModeSupport($workingDirectory, $startMode); + } + $availability = $rendererOption !== null && strtolower(trim($rendererOption)) === 'auto' + ? $this->rendererAvailability->getReport($workingDirectory) : null; $renderer = $this->rendererSelector->resolve( rendererOption: $rendererOption, gpuiAlias: (bool) $input->getOption('gpui-renderer'), canPrompt: $input->isInteractive() && $this->terminalInteractivity->supportsPrompts(), + availabilityReport: $availability, ); - } catch (InvalidArgumentException $exception) { + } catch (\RuntimeException|InvalidArgumentException $exception) { $output->writeln($exception->getMessage()); return Command::INVALID; } - $errorLogFile = $this->prepareErrorLogFile($workingDirectory); + $errorLogFile = $contextErrorLogFile ?? $this->prepareErrorLogFile($workingDirectory); + + if ((bool) $input->getOption('terminal-window') && $renderer->id === 'terminal') { + $environment = $this->launchCommandBuilder->buildEnvironment($renderer->id, $startMode, $launchContext); + $environment[TerminalSessionLauncher::DIAGNOSTIC_LOG_ENVIRONMENT] = $errorLogFile; + $resultCode = $this->terminalSession->launch($workingDirectory, + $this->launchCommandBuilder->buildGameCommandArguments($mainFile, $errorLogFile), $environment); + if ($resultCode === null) { + $output->writeln('The Terminal play session could not start: ' . ($this->terminalSession->getFailureReason() ?? 'unknown launch failure')); + } elseif ($resultCode !== 0) { + $output->writeln('The game exited unexpectedly. Check the error log for details: ' . $errorLogFile); + } + return $resultCode === 0 ? Command::SUCCESS : Command::FAILURE; + } if (! (bool) $input->getOption('no-tmux') && $this->shouldLaunchInTmux()) { - return $this->launchInTmux($workingDirectory, $mainFile, $errorLogFile, $renderer->id); + return $this->launchInTmux($workingDirectory, $mainFile, $errorLogFile, $renderer->id, $input, $output, $startMode, $launchContext); } + $this->rendererUpdateOffer->offer($workingDirectory, $renderer->id, $input, $output); + $resultCode = $this->gameProcessLauncher->launch( workingDirectory: $workingDirectory, mainFile: $mainFile, errorLogFile: $errorLogFile, rendererId: $renderer->id, + startMode: $startMode, + launchContext: $launchContext, ); if ($resultCode === null) { @@ -189,13 +259,23 @@ private function launchInTmux( string $mainFile, string $errorLogFile, string $rendererId, + InputInterface $input, + OutputInterface $output, + ?string $startMode = null, + ?string $launchContext = null, ): int { $sessionName = 'ichiloto-play-' . preg_replace('/[^A-Za-z0-9_-]+/', '-', basename($workingDirectory)); - $gameCommand = $this->launchCommandBuilder->buildGameCommand($mainFile, $errorLogFile, $rendererId); + $gameCommand = $this->launchCommandBuilder->buildGameCommand($mainFile, $errorLogFile, $rendererId, $startMode, $launchContext); $launchCommand = $this->launchCommandBuilder->buildCrashPreservingCommand($gameCommand, 'Ichiloto game'); - if (! $this->tmuxSessionExists($sessionName)) { + $sessionExists = $this->tmuxSessionExists($sessionName); + if ($sessionExists && $launchContext !== null) { + $output->writeln('A launch context cannot be applied to an existing tmux game. Use --no-tmux for a new isolated launch.'); + return Command::INVALID; + } + if (! $sessionExists) { + $this->rendererUpdateOffer->offer($workingDirectory, $rendererId, $input, $output); passthru($this->launchCommandBuilder->buildTmuxNewSessionCommand( sessionName: $sessionName, workingDirectory: $workingDirectory, diff --git a/src/Commands/RendererListCommand.php b/src/Commands/RendererListCommand.php new file mode 100644 index 0000000..ecb7709 --- /dev/null +++ b/src/Commands/RendererListCommand.php @@ -0,0 +1,51 @@ +addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'Project whose Engine reports renderer availability.') + ->addOption('json', null, InputOption::VALUE_NONE, 'Return the ichiloto.renderers/1 discovery report.'); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + try { + $report = $this->availability->getReport((string) ($input->getOption('directory') ?? getcwd() ?: '.')); + if ($input->getOption('json')) { + $output->writeln(json_encode($report, JSON_THROW_ON_ERROR), OutputInterface::OUTPUT_RAW); + return Command::SUCCESS; + } + foreach ($report['renderers'] as $renderer) { + $output->writeln(sprintf('%s (%s): %s', $renderer['displayName'], $renderer['id'], + $renderer['available'] ? 'available' : 'unavailable - ' . $renderer['reason']), OutputInterface::OUTPUT_RAW); + } + $output->writeln('Preferred renderer: ' . ($report['preferredId'] ?? 'none'), OutputInterface::OUTPUT_RAW); + return Command::SUCCESS; + } catch (Throwable $error) { + $errors = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + $errors->writeln('Renderer discovery failed: ' . $error->getMessage(), OutputInterface::OUTPUT_RAW); + return Command::FAILURE; + } + } +} diff --git a/src/Commands/RendererUpdateCommand.php b/src/Commands/RendererUpdateCommand.php new file mode 100644 index 0000000..4591024 --- /dev/null +++ b/src/Commands/RendererUpdateCommand.php @@ -0,0 +1,54 @@ +addArgument('renderer', InputArgument::OPTIONAL, 'Renderer ID to update.', 'gpui') + ->addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'Project whose Engine declares the renderer source.'); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $directory = $input->getOption('directory') ?? getcwd(); + $renderer = $input->getArgument('renderer'); + if (! is_string($directory) || ! is_dir($directory) || ! is_string($renderer)) { + $output->writeln('Supply a valid project directory and renderer ID.'); + return Command::INVALID; + } + $project = realpath($directory); + if ($project === false || ! is_file($project . '/vendor/autoload.php')) { + $output->writeln('Project dependencies are not installed in ' . OutputFormatter::escape($directory) . '.'); + return Command::FAILURE; + } + try { + $installed = $this->updater->update($project, $renderer, $output); + } catch (Throwable $error) { + $output->writeln('Renderer update failed: ' . OutputFormatter::escape($error->getMessage()) . ''); + return Command::FAILURE; + } + $output->writeln($installed ? 'Renderer updated.' : 'Renderer is already current.'); + return Command::SUCCESS; + } +} diff --git a/src/Commands/UpgradeCommand.php b/src/Commands/UpgradeCommand.php index ee0853b..15c49b6 100644 --- a/src/Commands/UpgradeCommand.php +++ b/src/Commands/UpgradeCommand.php @@ -4,26 +4,51 @@ namespace Ichiloto\Console\Commands; -use Ichiloto\Console\Support\LegacyProjectUpgrader; +use Ichiloto\Console\Support\TerminalInteractivity; +use Ichiloto\Console\Upgrade\GitWorkingTree; +use Ichiloto\Console\Upgrade\ProjectFileWriter; +use Ichiloto\Console\Upgrade\ProjectUpgradeChain; +use Ichiloto\Console\Upgrade\ProjectUpgradeContext; +use Ichiloto\Console\Upgrade\UpgradeReportDocument; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Throwable; +use function Laravel\Prompts\confirm; +/** + * Brings a project to the engine's format: detect the version, explain each + * pending step, protect uncommitted work, then run the chain and report what + * needs a person. + */ #[AsCommand( name: 'upgrade', - description: 'Add current mandatory metadata to an existing Ichiloto project.', + description: 'Convert an existing Ichiloto project to the current project format.', )] final class UpgradeCommand extends Command { + private readonly ProjectUpgradeChain $chain; + + public function __construct( + ?ProjectUpgradeChain $chain = null, + private readonly GitWorkingTree $git = new GitWorkingTree(), + private readonly TerminalInteractivity $terminalInteractivity = new TerminalInteractivity(), + ) { + $this->chain = $chain ?? ProjectUpgradeChain::createDefault(); + parent::__construct(); + } + public function configure(): void { $this - ->addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'The existing project directory.') + ->addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'The existing project directory. Defaults to the current directory.') ->addOption('id', null, InputOption::VALUE_REQUIRED, 'The permanent vendor/project save identity to use when one is missing.') - ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the upgrade without writing files.'); + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'List what the upgrade would change without writing files.') + ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Upgrade without asking for confirmation (required when not interactive).') + ->addOption('allow-dirty', null, InputOption::VALUE_NONE, 'Upgrade even though the project has uncommitted Git changes.'); } public function execute(InputInterface $input, OutputInterface $output): int @@ -32,49 +57,115 @@ public function execute(InputInterface $input, OutputInterface $output): int $dryRun = (bool) $input->getOption('dry-run'); try { - $result = new LegacyProjectUpgrader()->upgrade( + $context = new ProjectUpgradeContext( $workingDirectory, is_string($input->getOption('id')) ? $input->getOption('id') : null, - $dryRun, ); + $version = $this->chain->getRecordedVersion($context); + + if ($version > $this->chain->currentVersion) { + $output->writeln(sprintf( + 'This project uses format %d, which is newer than this Ichiloto reads (%d). Update Ichiloto instead.', + $version, + $this->chain->currentVersion, + )); + + return Command::FAILURE; + } + + if ($version === $this->chain->currentVersion) { + $output->writeln(sprintf('✓ No upgrade is needed; the project is already at format %d.', $version)); + + return Command::SUCCESS; + } + + $plans = $this->chain->createPlans($context); } catch (Throwable $throwable) { - $output->writeln('The project could not be upgraded: ' . $throwable->getMessage() . ''); + $output->writeln('The project could not be upgraded: ' . OutputFormatter::escape($throwable->getMessage()) . ''); return Command::FAILURE; } - $verb = $dryRun ? 'Would add' : 'Added'; + $output->writeln(sprintf( + 'This project is at format %d; Ichiloto reads format %d. The upgrade will:', + $version, + $this->chain->currentVersion, + )); - if ($result['configChanged']) { - $output->writeln(sprintf( - '✓ %s stable project id %s to ichiloto.json.', - $verb, - $result['projectId'], - )); + foreach ($plans as $plan) { + $output->writeln(''); + $output->writeln(sprintf(' Format %d, %s:', $plan->targetVersion, $plan->title)); + + foreach ($plan->changes as $change) { + $output->writeln(' - ' . OutputFormatter::escape($change)); + } + } + + $output->writeln(''); + + if ($dryRun) { + $output->writeln('Dry run only; no files were changed.'); + + return Command::SUCCESS; } - if ($result['manifestChanged']) { + $uncommitted = $this->git->getUncommittedChanges($context->root); + + if ($uncommitted !== null && $uncommitted !== [] && ! $input->getOption('allow-dirty')) { $output->writeln(sprintf( - '✓ %s assets/Data/save-compatibility.php at legacy content version 0.', - $dryRun ? 'Would create' : 'Created', + 'The project has %d uncommitted %s. Commit or stash them first so the upgrade is one reviewable change, or pass --allow-dirty.', + count($uncommitted), + count($uncommitted) === 1 ? 'change' : 'changes', )); + + return Command::FAILURE; } - if (! $result['configChanged'] && ! $result['manifestChanged']) { - $output->writeln('✓ No upgrade is needed; mandatory save metadata is already present.'); + if (! $input->getOption('yes')) { + if (! $input->isInteractive() || ! $this->terminalInteractivity->supportsPrompts()) { + $output->writeln('Nothing was changed. Pass --yes to upgrade without a prompt, or --dry-run to only list the changes.'); + + return Command::FAILURE; + } + + if (! confirm('Upgrade the project now?', false)) { + $output->writeln('Nothing was changed.'); + + return Command::SUCCESS; + } } - $output->writeln(sprintf( - 'Keep project id %s unchanged once saves exist.', - $result['projectId'], - )); + try { + $reports = $this->chain->runPendingSteps($context); + $document = new UpgradeReportDocument($version, $this->chain->currentVersion, $reports); + $reportPath = $context->getPath(UpgradeReportDocument::FILENAME); + new ProjectFileWriter()->writeAtomically($reportPath, $document->renderMarkdown()); + } catch (Throwable $throwable) { + $reached = $this->chain->getRecordedVersion($context); + $output->writeln('The upgrade stopped: ' . OutputFormatter::escape($throwable->getMessage()) . ''); + $output->writeln(sprintf('The project is at format %d. Fix the problem and run ichiloto upgrade again to continue.', $reached)); - if ($dryRun) { - $output->writeln('Dry run only; no files were changed.'); - } else { - $output->writeln('Run ichiloto validate to check the upgraded project.'); + return Command::FAILURE; } + $output->writeln(sprintf('✓ Upgraded the project to format %d.', $this->chain->currentVersion)); + + foreach ($document->getFollowUps() as $heading => $sections) { + $output->writeln(''); + $output->writeln("{$heading}"); + + foreach ($sections as $section => $items) { + $output->writeln(" {$section}:"); + + foreach ($items as $item) { + $output->writeln(' - ' . OutputFormatter::escape($item)); + } + } + } + + $output->writeln(''); + $output->writeln('Follow-up list written to ' . $reportPath); + return Command::SUCCESS; } } diff --git a/src/Commands/ValidateCommand.php b/src/Commands/ValidateCommand.php index b275cfb..0ecc543 100644 --- a/src/Commands/ValidateCommand.php +++ b/src/Commands/ValidateCommand.php @@ -2,16 +2,21 @@ namespace Ichiloto\Console\Commands; +use Ichiloto\Console\Support\ProjectFormatCheck; +use Ichiloto\Editor\Actors\ActorIdentityMigration; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Editor\Validation\ActorReferenceValidator; use Ichiloto\Editor\Validation\Issue; use Ichiloto\Editor\Validation\ProjectValidator; use Ichiloto\Editor\Validation\Severity; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Throwable; +use function Laravel\Prompts\confirm; #[AsCommand( name: 'validate', @@ -23,23 +28,36 @@ public function configure(): void { $this ->addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'The project directory.') - ->addOption('strict', 's', InputOption::VALUE_NONE, 'Treat warnings as failures too.'); + ->addOption('strict', 's', InputOption::VALUE_NONE, 'Treat warnings as failures too.') + ->addOption('migrate-actor-ids', null, InputOption::VALUE_NONE, 'Freeze missing actor ids and repair legacy actor references.'); } public function execute(InputInterface $input, OutputInterface $output): int { - $workingDirectory = $input->getOption('directory') ?? getcwd() ?: '.'; + $workingDirectory = (string) ($input->getOption('directory') ?? getcwd() ?: '.'); if (is_not_valid_working_dir($workingDirectory)) { $output->writeln('The working directory is not valid: ' . $workingDirectory . ''); return Command::FAILURE; } + $workingDirectory = realpath($workingDirectory) ?: $workingDirectory; $this->bootstrapDependencies($workingDirectory); + $formatProblem = ProjectFormatCheck::getProblem($workingDirectory); + + if ($formatProblem !== null) { + // Older data would only produce misleading errors; the upgrade is what the project needs. + $output->writeln(''); + $output->writeln(' ! ' . OutputFormatter::escape($formatProblem)); + $output->writeln(''); + + return Command::FAILURE; + } try { $workspace = ProjectWorkspace::fromProject($workingDirectory); + $pendingActors = ActorIdentityMigration::getPendingActors($workspace->actorDatabase); $issues = new ProjectValidator()->validate($workspace); } catch (Throwable $throwable) { $output->writeln('The project could not be read: ' . $throwable->getMessage() . ''); @@ -49,6 +67,78 @@ public function execute(InputInterface $input, OutputInterface $output): int $this->report($output, $workspace->projectName, $issues); + if ($pendingActors !== []) { + $output->writeln(sprintf(' %d actor(s) have no explicit stable id:', count($pendingActors))); + foreach ($pendingActors as $actor) { + $output->writeln(sprintf(' %s (%s)', $actor->getName(), basename($actor->path))); + } + } + + $migrationRequested = (bool) $input->getOption('migrate-actor-ids'); + $hasMigrationCandidate = $pendingActors !== [] || $this->hasUnresolvedActorReferences($issues); + if ($migrationRequested || ($input->isInteractive() && $hasMigrationCandidate)) { + try { + $plan = ActorIdentityMigration::planProject($workingDirectory); + } catch (Throwable $throwable) { + $output->writeln('Actor id migration could not be planned: ' . $throwable->getMessage() . ''); + return $migrationRequested ? Command::FAILURE : $this->getValidationResult($input, $issues); + } + + $plannedPaths = $plan->getChangedPaths(); + if ($plannedPaths !== []) { + $output->writeln(sprintf(' Actor identity migration would update %d file(s):', count($plannedPaths))); + foreach ($plannedPaths as $path) { + $output->writeln(' ' . $path); + } + } + + $shouldMigrate = $plannedPaths !== [] && ($migrationRequested + || ($input->isInteractive() && confirm( + 'Freeze missing actor ids and update their legacy references?', + false, + ))); + + if ($shouldMigrate) { + try { + $changedPaths = $plan->apply(); + $workspace = ProjectWorkspace::fromProject($workingDirectory); + $issues = new ProjectValidator()->validate($workspace); + } catch (Throwable $throwable) { + $output->writeln('Actor id migration failed: ' . $throwable->getMessage() . ''); + return Command::FAILURE; + } + + $output->writeln(sprintf( + 'Added stable ids to %d actor(s) and updated %d file(s); validation after migration:', + count($pendingActors), + count($changedPaths), + )); + $this->report($output, $workspace->projectName, $issues); + } elseif ($plannedPaths !== []) { + $output->writeln('Run validate --migrate-actor-ids to apply this migration non-interactively.'); + } + } elseif ($pendingActors !== []) { + $output->writeln('Run validate --migrate-actor-ids to apply this migration non-interactively.'); + } + + return $this->getValidationResult($input, $issues); + } + + /** @param Issue[] $issues */ + private function hasUnresolvedActorReferences(array $issues): bool + { + foreach ($issues as $issue) { + if ($issue->code === ActorReferenceValidator::UNRESOLVED_ACTOR_REFERENCE) { + return true; + } + } + + return false; + } + + /** @param Issue[] $issues */ + private function getValidationResult(InputInterface $input, array $issues): int + { $errors = $this->countOf($issues, Severity::ERROR); $warnings = $this->countOf($issues, Severity::WARNING); diff --git a/src/Renderer/ProjectRendererAvailability.php b/src/Renderer/ProjectRendererAvailability.php new file mode 100644 index 0000000..8fd5086 --- /dev/null +++ b/src/Renderer/ProjectRendererAvailability.php @@ -0,0 +1,131 @@ +, preferredId: ?string} + */ + public function getReport(string $projectDirectory): array + { + $directory = realpath($projectDirectory); + if ($directory === false || ! is_dir($directory)) { + throw new RuntimeException('Supply a valid project directory for renderer discovery.'); + } + $autoload = $directory . '/vendor/autoload.php'; + if (! is_file($autoload) || ! is_readable($autoload)) { + throw new RuntimeException('Project dependencies are not installed; run composer install in ' . $directory . '.'); + } + + $probe = <<<'PHP' +try { + require $argv[1]; + $class = 'Ichiloto\Engine\Rendering\Launch\RendererRegistry'; + if (!class_exists($class)) { + throw new RuntimeException('The project Engine does not support renderer availability discovery.'); + } + $registry = new $class(); + if (!is_callable([$registry, 'getReport'])) { + throw new RuntimeException('The project Engine does not support RendererRegistry::getReport().'); + } + fwrite(STDOUT, json_encode($registry->getReport(), JSON_THROW_ON_ERROR)); +} catch (Throwable $error) { + fwrite(STDERR, $error->getMessage() . PHP_EOL); + exit(1); +} +PHP; + $response = $this->process->run([ + PHP_BINARY, '-d', 'memory_limit=512M', '-d', 'display_errors=stderr', '-d', 'log_errors=0', + '-r', $probe, $autoload, + ], $directory, timeoutSeconds: $this->timeoutSeconds); + try { + $report = json_decode($response, false, 16, JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new UnexpectedValueException('The project Engine returned malformed renderer availability JSON.', previous: $error); + } + if (! $report instanceof stdClass) { + throw new UnexpectedValueException('The project Engine returned a non-object renderer availability report.'); + } + $data = get_object_vars($report); + if (! is_array($data['renderers'] ?? null)) { + throw new UnexpectedValueException('Renderer availability requires a JSON list of renderers.'); + } + $data['renderers'] = array_map(static fn(mixed $renderer): mixed => $renderer instanceof stdClass + ? get_object_vars($renderer) : $renderer, $data['renderers']); + return self::validateReport($data); + } + + /** + * @param array $report + * @return array{schema: string, renderers: list, preferredId: ?string} + */ + public static function validateReport(array $report): array + { + if (! self::hasExactKeys($report, ['schema', 'renderers', 'preferredId']) || $report['schema'] !== self::SCHEMA) { + throw new UnexpectedValueException('Unsupported renderer availability report schema; expected ' . self::SCHEMA . '.'); + } + if (! is_array($report['renderers']) || ! array_is_list($report['renderers']) + || count($report['renderers']) > self::MAX_RENDERERS) { + throw new UnexpectedValueException('Renderer availability requires a list of at most 32 renderers.'); + } + $statuses = []; + $renderers = []; + foreach ($report['renderers'] as $renderer) { + if (! is_array($renderer) || ! self::hasExactKeys($renderer, ['id', 'displayName', 'available', 'reason']) + || ! is_string($renderer['id']) || strlen($renderer['id']) > self::MAX_ID_BYTES + || ! preg_match('/^[a-z][a-z0-9_-]*$/D', $renderer['id']) + || ! self::isValidText($renderer['displayName'], self::MAX_NAME_BYTES) + || ! is_bool($renderer['available']) + || ($renderer['available'] ? $renderer['reason'] !== null + : ! self::isValidText($renderer['reason'], self::MAX_REASON_BYTES))) { + throw new UnexpectedValueException('Malformed renderer availability entry.'); + } + if (array_key_exists($renderer['id'], $statuses)) { + throw new UnexpectedValueException('Duplicate renderer availability ID: ' . $renderer['id'] . '.'); + } + $statuses[$renderer['id']] = $renderer['available']; + $renderers[] = ['id' => $renderer['id'], 'displayName' => $renderer['displayName'], + 'available' => $renderer['available'], 'reason' => $renderer['reason']]; + } + $preferredId = $report['preferredId']; + if (($preferredId === null && in_array(true, $statuses, true)) + || ($preferredId !== null && (! is_string($preferredId) || ($statuses[$preferredId] ?? false) !== true))) { + throw new UnexpectedValueException('The preferred renderer must identify an available renderer, or be null when none is available.'); + } + return ['schema' => self::SCHEMA, 'renderers' => $renderers, 'preferredId' => $preferredId]; + } + + /** @param array $value @param list $keys */ + private static function hasExactKeys(array $value, array $keys): bool + { + return count($value) === count($keys) && array_diff($keys, array_keys($value)) === []; + } + + private static function isValidText(mixed $value, int $maximumBytes): bool + { + return is_string($value) && trim($value) !== '' && strlen($value) <= $maximumBytes + && ! preg_match('/[\x00-\x1F\x7F]/', $value); + } +} diff --git a/src/Renderer/RendererSelector.php b/src/Renderer/RendererSelector.php index 0d9704a..2b75e64 100644 --- a/src/Renderer/RendererSelector.php +++ b/src/Renderer/RendererSelector.php @@ -30,11 +30,28 @@ public function __construct( : Closure::fromCallable($prompt); } + /** @param array{schema: string, renderers: list, preferredId: ?string}|null $availabilityReport */ public function resolve( ?string $rendererOption, bool $gpuiAlias, bool $canPrompt, + ?array $availabilityReport = null, ): RendererDescriptor { + if ($rendererOption !== null && strtolower(trim($rendererOption)) === 'auto') { + if ($availabilityReport === null) { + throw new InvalidArgumentException('--renderer=auto requires a project renderer availability report.'); + } + $report = ProjectRendererAvailability::validateReport($availabilityReport); + if ($report['preferredId'] === null) { + throw new UnexpectedValueException('No renderer is available for this project.'); + } + $renderer = $this->registry->require($report['preferredId']); + if ($gpuiAlias && $renderer->id !== 'gpui') { + throw new InvalidArgumentException('The --gpui-renderer alias conflicts with --renderer=auto selecting ' . $renderer->id . '.'); + } + return $renderer; + } + if ($rendererOption !== null) { $renderer = $this->registry->require($rendererOption); diff --git a/src/Support/EditorProjectBootstrap.php b/src/Support/EditorProjectBootstrap.php new file mode 100644 index 0000000..61fa547 --- /dev/null +++ b/src/Support/EditorProjectBootstrap.php @@ -0,0 +1,81 @@ + $paths) { + if (! is_string($namespace) || ! str_starts_with($class, $namespace)) { + continue; + } + + $relativeClass = str_replace('\\', DIRECTORY_SEPARATOR, substr($class, strlen($namespace))) . '.php'; + + foreach ((array) $paths as $path) { + $filename = rtrim($projectDirectory, DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . trim((string) $path, DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . $relativeClass; + + if (is_file($filename)) { + require_once $filename; + } + } + } + }); + } +} diff --git a/src/Support/GameLaunchCommandBuilder.php b/src/Support/GameLaunchCommandBuilder.php index 588512b..3a18e42 100644 --- a/src/Support/GameLaunchCommandBuilder.php +++ b/src/Support/GameLaunchCommandBuilder.php @@ -7,6 +7,60 @@ final class GameLaunchCommandBuilder { public const RENDERER_ENVIRONMENT_VARIABLE = 'ICHILOTO_RENDERER'; + public const STARTUP_ENVIRONMENT_VARIABLE = 'ICHILOTO_START_MODE'; + public const LAUNCH_CONTEXT_ENVIRONMENT_VARIABLE = 'ICHILOTO_LAUNCH_CONTEXT'; + public const MAX_LAUNCH_CONTEXT_BYTES = 65536; + + /** @return array */ + public function buildEnvironment(string $rendererId, ?string $startMode = null, ?string $launchContext = null): array + { + $environment = getenv(); + $environment = is_array($environment) ? $environment : []; + unset($environment[self::LAUNCH_CONTEXT_ENVIRONMENT_VARIABLE]); + $environment[self::RENDERER_ENVIRONMENT_VARIABLE] = $rendererId; + if ($launchContext !== null) { + $this->validateLaunchContextTransport($launchContext); + $this->rejectLaunchContextModeConflict($startMode); + unset($environment[self::STARTUP_ENVIRONMENT_VARIABLE]); + $environment[self::LAUNCH_CONTEXT_ENVIRONMENT_VARIABLE] = $launchContext; + } + if ($startMode !== null) { + $this->validateStartMode($startMode); + $environment[self::STARTUP_ENVIRONMENT_VARIABLE] = $startMode; + } + + return $environment; + } + + /** Console owns transport shape only; the project Engine owns the schema. */ + public function validateLaunchContextTransport(string $json): void + { + if (strlen($json) > self::MAX_LAUNCH_CONTEXT_BYTES) { + throw new \InvalidArgumentException('Launch context exceeds 65536 bytes.'); + } + try { + $object = json_decode($json, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $error) { + throw new \InvalidArgumentException('Launch context must be a JSON object.', previous: $error); + } + if (!$object instanceof \stdClass) { + throw new \InvalidArgumentException('Launch context must be a JSON object.'); + } + } + + public function rejectLaunchContextModeConflict(?string $startMode): void + { + if ($startMode !== null || getenv(self::STARTUP_ENVIRONMENT_VARIABLE) !== false) { + throw new \InvalidArgumentException('Launch context cannot be combined with --start or ICHILOTO_START_MODE.'); + } + } + + public function validateStartMode(string $startMode): void + { + if (!in_array($startMode, ['title', 'new-game'], true)) { + throw new \InvalidArgumentException('Unknown game start mode. Use title or new-game.'); + } + } /** * @return list @@ -27,20 +81,36 @@ public function buildGameCommandArguments(string $mainFile, string $errorLogFile ]; } - public function buildGameCommand(string $mainFile, string $errorLogFile, string $rendererId): string + public function buildGameCommand(string $mainFile, string $errorLogFile, string $rendererId, ?string $startMode = null, ?string $launchContext = null): string { $arguments = array_map( static fn (string $argument): string => escapeshellarg($argument), $this->buildGameCommandArguments($mainFile, $errorLogFile), ); - return sprintf( + $command = sprintf( '%s=%s %s 2>> %s', self::RENDERER_ENVIRONMENT_VARIABLE, escapeshellarg($rendererId), implode(' ', $arguments), escapeshellarg($errorLogFile), ); + if ($startMode !== null) { + $this->validateStartMode($startMode); + $command = self::STARTUP_ENVIRONMENT_VARIABLE . '=' . escapeshellarg($startMode) . ' ' . $command; + } + + // A tmux server may hold stale startup intent independently of this Console. + if ($launchContext !== null) { + $this->validateLaunchContextTransport($launchContext); + $this->rejectLaunchContextModeConflict($startMode); + $command = 'env -u ' . self::STARTUP_ENVIRONMENT_VARIABLE . ' ' + . self::LAUNCH_CONTEXT_ENVIRONMENT_VARIABLE . '=' . escapeshellarg($launchContext) . ' ' . $command; + } else { + $command = 'env -u ' . self::LAUNCH_CONTEXT_ENVIRONMENT_VARIABLE . ' ' . $command; + } + + return $command; } public function buildCrashPreservingCommand(string $command, string $label): string diff --git a/src/Support/GameProcessLauncher.php b/src/Support/GameProcessLauncher.php index 8f13677..fb04c1d 100644 --- a/src/Support/GameProcessLauncher.php +++ b/src/Support/GameProcessLauncher.php @@ -19,20 +19,16 @@ public function launch( string $mainFile, string $errorLogFile, string $rendererId, + ?string $startMode = null, + ?string $launchContext = null, ): ?int { + $environment = $this->commandBuilder->buildEnvironment($rendererId, $startMode, $launchContext); $errorStream = @fopen($errorLogFile, 'ab'); if (! is_resource($errorStream)) { return null; } - $environment = getenv(); - - if (! is_array($environment)) { - $environment = []; - } - - $environment[GameLaunchCommandBuilder::RENDERER_ENVIRONMENT_VARIABLE] = $rendererId; $pipes = []; try { diff --git a/src/Support/GuiEditorLocator.php b/src/Support/GuiEditorLocator.php new file mode 100644 index 0000000..57733de --- /dev/null +++ b/src/Support/GuiEditorLocator.php @@ -0,0 +1,81 @@ +configured !== null) { + return is_file($this->configured) && is_executable($this->configured) ? $this->configured : null; + } + + foreach ($this->getCandidates() as $candidate) { + if (is_file($candidate) && is_executable($candidate)) { + return $candidate; + } + } + + return null; + } + + /** Why no executable was found, and how to get one. */ + public function describeMissing(): string + { + if ($this->configured !== null) { + return sprintf('%s names %s, which is not an executable file.', self::ENVIRONMENT, $this->configured); + } + + return sprintf( + "The GUI editor is not built. Build it with `cargo build --release` in %s, or set %s to its executable.\nLooked for: %s", + dirname($this->getCandidates()[array_key_last($this->getCandidates())], 3), + self::ENVIRONMENT, + implode(', ', $this->getCandidates()), + ); + } + + /** @return list */ + private function getCandidates(): array + { + $checkout = dirname(rtrim($this->consoleRoot, DIRECTORY_SEPARATOR)) . DIRECTORY_SEPARATOR . 'gui-editor'; + + return [ + ...(($this->platform ?? PHP_OS_FAMILY) === 'Darwin' ? [$checkout . '/' . self::MACOS_BUNDLE_EXECUTABLE] : []), + $checkout . '/target/release/' . self::BINARY, + $checkout . '/target/debug/' . self::BINARY, + ]; + } +} diff --git a/src/Support/LegacyProjectUpgrader.php b/src/Support/LegacyProjectUpgrader.php deleted file mode 100644 index fae1624..0000000 --- a/src/Support/LegacyProjectUpgrader.php +++ /dev/null @@ -1,182 +0,0 @@ -readJsonObject($configPath, 'project configuration'); - $existingId = trim(is_string($config['id'] ?? null) ? $config['id'] : ''); - $requestedId = trim((string) $requestedId); - - if ($existingId !== '' && $requestedId !== '' && $requestedId !== $existingId) { - throw new RuntimeException(sprintf( - 'The project already has save identity "%s"; it cannot be replaced with "%s".', - $existingId, - $requestedId, - )); - } - - $projectId = $existingId !== '' - ? $existingId - : ($requestedId !== '' ? $this->assertProjectId($requestedId) : $this->deriveProjectId($canonicalRoot, $config)); - $configChanged = $existingId === ''; - $manifestPath = $canonicalRoot . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'Data' - . DIRECTORY_SEPARATOR . 'save-compatibility.php'; - - if (file_exists($manifestPath) && ! is_file($manifestPath)) { - throw new RuntimeException("The save compatibility manifest path is not a file: {$manifestPath}"); - } - - $manifestChanged = ! is_file($manifestPath); - - if (! $dryRun) { - if ($configChanged) { - $config = ['id' => $projectId] + $config; - $this->writeAtomically( - $configPath, - json_encode( - $config, - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, - ) . PHP_EOL, - ); - } - - if ($manifestChanged) { - $this->writeAtomically($manifestPath, SaveCompatibilityMetadata::renderBaseline()); - } - } - - return [ - 'projectRoot' => $canonicalRoot, - 'projectId' => $projectId, - 'configChanged' => $configChanged, - 'manifestChanged' => $manifestChanged, - 'dryRun' => $dryRun, - ]; - } - - /** @param array $config */ - private function deriveProjectId(string $projectRoot, array $config): string - { - $composerPath = $projectRoot . DIRECTORY_SEPARATOR . 'composer.json'; - - if (is_file($composerPath)) { - try { - $composer = $this->readJsonObject($composerPath, 'Composer configuration'); - $composerName = trim(is_string($composer['name'] ?? null) ? $composer['name'] : ''); - - if ($composerName !== '' && $this->isCanonicalProjectId($composerName)) { - return $composerName; - } - } catch (RuntimeException) { - // A legacy project's unrelated Composer problem should not - // prevent deriving an identity from its Ichiloto metadata. - } - } - - $projectName = trim(is_string($config['name'] ?? null) ? $config['name'] : ''); - $slug = $this->slugify($projectName !== '' ? $projectName : basename($projectRoot)); - - if ($slug === '') { - throw new RuntimeException('A stable project id could not be derived. Pass one with --id=vendor/project.'); - } - - return 'ichiloto/' . $slug; - } - - private function assertProjectId(string $projectId): string - { - if (! $this->isCanonicalProjectId($projectId)) { - throw new RuntimeException('The project id must use stable lowercase vendor/project notation.'); - } - - return $projectId; - } - - private function isCanonicalProjectId(string $projectId): bool - { - return preg_match('/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/', $projectId) === 1; - } - - private function slugify(string $value): string - { - $slug = preg_replace('/[^A-Za-z0-9]+/', '-', trim($value)) ?? ''; - - return strtolower(trim($slug, '-')); - } - - /** @return array */ - private function readJsonObject(string $path, string $label): array - { - try { - $data = json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR); - } catch (JsonException $exception) { - throw new RuntimeException(sprintf('Unable to parse the %s at %s: %s', $label, $path, $exception->getMessage()), previous: $exception); - } - - if (! is_array($data) || array_is_list($data)) { - throw new RuntimeException(sprintf('The %s at %s must contain a JSON object.', $label, $path)); - } - - return $data; - } - - private function writeAtomically(string $path, string $contents): void - { - $directory = dirname($path); - $permissions = is_file($path) - ? (fileperms($path) & 0777) - : (0666 & ~umask()); - - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); - } - - $temporaryPath = tempnam($directory, '.ichiloto-upgrade-'); - - if (! is_string($temporaryPath)) { - throw new RuntimeException("Unable to prepare an atomic write for {$path}."); - } - - try { - if (file_put_contents($temporaryPath, $contents) === false - || ! chmod($temporaryPath, $permissions) - || ! rename($temporaryPath, $path)) { - throw new RuntimeException("Unable to write {$path}."); - } - } finally { - if (is_file($temporaryPath)) { - unlink($temporaryPath); - } - } - } -} diff --git a/src/Support/NewProjectScaffolder.php b/src/Support/NewProjectScaffolder.php index 066e1cf..4eaa048 100644 --- a/src/Support/NewProjectScaffolder.php +++ b/src/Support/NewProjectScaffolder.php @@ -5,6 +5,7 @@ namespace Ichiloto\Console\Support; use Ichiloto\Console\Util\Path; +use Ichiloto\Engine\Core\ProjectFormat; use RuntimeException; final class NewProjectScaffolder @@ -57,10 +58,6 @@ public function scaffold(array $blueprint): array Path::join($blueprint['targetDirectory'], '.gitignore'), $this->renderGitIgnore(), ); - $files[] = $this->writeFile( - Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'abilities.php'), - $this->renderPhpArrayFile([]), - ); $files[] = $this->writeFile( Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'animations.php'), $this->renderPhpArrayFile([]), @@ -71,19 +68,15 @@ public function scaffold(array $blueprint): array ); $files[] = $this->writeFile( Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'enemies.php'), - $this->renderPhpArrayFile([]), + $this->renderEnemiesBarrel(), ); $files[] = $this->writeFile( Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'items.php'), - $this->renderPhpArrayFile([]), - ); - $files[] = $this->writeFile( - Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'magic.php'), - $this->renderPhpArrayFile([]), + $this->renderItemsBarrel(), ); $files[] = $this->writeFile( Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'skills.php'), - $this->renderPhpArrayFile([]), + $this->renderSkillsBarrel(), ); $files[] = $this->writeFile( Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'save-compatibility.php'), @@ -93,7 +86,7 @@ public function scaffold(array $blueprint): array Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'system.php'), $this->renderSystemData( title: $blueprint['displayName'], - heroId: $blueprint['heroId'], + heroIdentity: $blueprint['heroName'], battleEngine: $blueprint['battleEngine'], ), ); @@ -247,6 +240,7 @@ private function renderIchilotoConfig(string $projectId, string $displayName, st { return json_encode([ 'id' => $projectId, + ProjectFormat::KEY => ProjectFormat::CURRENT, 'name' => $displayName, 'description' => 'A terminal-born RPG forged with the Ichiloto Engine.', 'version' => '0.1.0', @@ -504,11 +498,12 @@ private function renderGitIgnore(): string !logs/.gitkeep .data/saves/* !.data/saves/.gitkeep +/.data/player-settings.json *.iedata TXT; } - private function renderSystemData(string $title, string $heroId, string $battleEngine): string + private function renderSystemData(string $title, string $heroIdentity, string $battleEngine): string { return $this->renderPhpArrayFile([ 'title' => $title, @@ -516,7 +511,7 @@ private function renderSystemData(string $title, string $heroId, string $battleE 'amount' => 0, ], 'startingParty' => [ - $heroId, + $heroIdentity, ], 'startingInventory' => [], 'startingPositions' => [ @@ -591,6 +586,7 @@ private function renderActorData(string $heroName): string 'class' => Character::class, 'data' => [ 'name' => HERO_NAME, + 'id' => HERO_NAME, 'description' => '', 'level' => 1, 'currentExp' => 0, @@ -659,6 +655,34 @@ private function renderCollisionDictionary(): string PHP; } + /** Skills are authored one record per numbered file in Skills/; this file loads them. */ + private function renderSkillsBarrel(): string + { + return "|list $payload */ diff --git a/src/Support/ProjectFormatCheck.php b/src/Support/ProjectFormatCheck.php new file mode 100644 index 0000000..4395ef6 --- /dev/null +++ b/src/Support/ProjectFormatCheck.php @@ -0,0 +1,39 @@ +getMessage(); + } + + return null; + } +} diff --git a/src/Support/ProjectStartupCheck.php b/src/Support/ProjectStartupCheck.php new file mode 100644 index 0000000..2ba14f0 --- /dev/null +++ b/src/Support/ProjectStartupCheck.php @@ -0,0 +1,90 @@ +validateLaunchContextTransport($json); + $probe = <<<'PHP' +require $argv[1]; +$class = 'Ichiloto\\Engine\\Core\\GameLaunchContext'; +$roots = 'Ichiloto\\Engine\\IO\\LaunchDataRoot'; +if (!class_exists($class) || !is_callable([$class, 'fromArray']) || !is_callable([$class, 'consumeEnvironment']) + || !defined($class . '::ENVIRONMENT_VARIABLE') || $class::ENVIRONMENT_VARIABLE !== 'ICHILOTO_LAUNCH_CONTEXT' + || !defined($class . '::MAX_TRANSPORT_BYTES') || $class::MAX_TRANSPORT_BYTES !== 65536 + || !is_callable([$roots, 'validate']) || !is_callable([$roots, 'validatePath'])) { + fwrite(STDERR, 'The project Engine does not support launch contexts. Update its Engine before using --launch-context.' . PHP_EOL); + exit(1); +} +try { + $object = json_decode($argv[2], flags: JSON_THROW_ON_ERROR); + // Preserve JSON object/list distinctions before the Engine's array schema check. + foreach (['start', 'battleTest'] as $key) { + if (property_exists($object, $key) && !$object->$key instanceof stdClass) { + throw new InvalidArgumentException("Launch $key must be a JSON object."); + } + } + $context = $class::fromArray(json_decode($argv[2], true, flags: JSON_THROW_ON_ERROR)); + $errorLogFile = null; + if ($context->dataRoot !== null) { + $root = $roots::validate($context->dataRoot, dirname($argv[1], 2)); + $logs = $roots::validatePath($root, $root . DIRECTORY_SEPARATOR . 'logs'); + if (!is_dir($logs) && !@mkdir($logs, 0700)) { + throw new RuntimeException('Could not create isolated launcher logs.'); + } + $errorLogFile = $roots::validatePath($root, $logs . DIRECTORY_SEPARATOR . 'error.log'); + $stream = @fopen($errorLogFile, 'ab'); + if (!is_resource($stream)) { + throw new RuntimeException('Could not open the isolated launcher error log.'); + } + fclose($stream); + $roots::validate($root, dirname($argv[1], 2)); + $roots::validatePath($root, $errorLogFile); + } + echo json_encode(['errorLogFile' => $errorLogFile], JSON_THROW_ON_ERROR); +} catch (Throwable $error) { + fwrite(STDERR, 'Invalid launch context: ' . $error->getMessage() . PHP_EOL); + exit(1); +} +PHP; + $result = $this->process->run([PHP_BINARY, '-d', 'memory_limit=512M', '-d', 'display_errors=stderr', + '-d', 'log_errors=0', '-r', $probe, $projectDirectory . '/vendor/autoload.php', $json], + $projectDirectory, timeoutSeconds: 5); + $report = json_decode($result, true); + if (!is_array($report) || array_keys($report) !== ['errorLogFile'] + || ($report['errorLogFile'] !== null && !is_string($report['errorLogFile']))) { + throw new RuntimeException('The project Engine returned an invalid launch-context support result.'); + } + return $report['errorLogFile']; + } + + public function requireModeSupport(string $projectDirectory, string $mode): void + { + $probe = <<<'PHP' +require $argv[1]; +$class = 'Ichiloto\\Engine\\Core\\GameStartupMode'; +if (!enum_exists($class) || !is_callable([$class, 'tryFrom']) || $class::tryFrom($argv[2]) === null + || !defined($class . '::ENVIRONMENT_VARIABLE') || $class::ENVIRONMENT_VARIABLE !== 'ICHILOTO_START_MODE') { + fwrite(STDERR, 'The project Engine does not support explicit game startup. Update its Engine before using --start.' . PHP_EOL); + exit(1); +} +echo 'supported'; +PHP; + $result = $this->process->run([PHP_BINARY, '-d', 'memory_limit=512M', '-d', 'display_errors=stderr', + '-d', 'log_errors=0', '-r', $probe, $projectDirectory . '/vendor/autoload.php', $mode], + $projectDirectory, timeoutSeconds: 5); + if ($result !== 'supported') { + throw new RuntimeException('The project Engine returned an invalid explicit-startup support result.'); + } + } +} diff --git a/src/Support/RendererPackageInstaller.php b/src/Support/RendererPackageInstaller.php index 1b9f697..ea2ec4b 100644 --- a/src/Support/RendererPackageInstaller.php +++ b/src/Support/RendererPackageInstaller.php @@ -328,37 +328,55 @@ private function readInstalledManifest(string $installedDirectory): array $contents = is_file($manifestFile) ? @file_get_contents($manifestFile) : false; if ($contents === false) { - throw new RuntimeException('The existing renderer manifest cannot be read; installation was refused.'); + throw $this->createManifestException($manifestFile, 'cannot be read', + 'Check that this path is a regular file and restore its read permissions for the current user.'); } try { $manifest = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); } catch (JsonException $error) { - throw new RuntimeException('The existing renderer manifest is not valid JSON; installation was refused.', previous: $error); + throw $this->createManifestException($manifestFile, 'is not valid JSON', + 'Back up this file, then repair its JSON or restore a known-good manifest backup, preserving all renderer registrations.', $error); } if (! is_array($manifest) || ($manifest['version'] ?? null) !== self::MANIFEST_VERSION || ! is_array($manifest['renderers'] ?? null)) { - throw new RuntimeException('The existing renderer manifest has an unsupported schema; installation was refused.'); + throw $this->createManifestException($manifestFile, 'has an unsupported schema', + 'Use a Console version that supports this manifest version. If the file is damaged, back it up and restore a known-good manifest backup.'); } foreach ($manifest['renderers'] as $renderer => $platforms) { if (! is_string($renderer) || ! is_array($platforms)) { - throw new RuntimeException('The existing renderer manifest has invalid registrations; installation was refused.'); + throw $this->createManifestException($manifestFile, 'has invalid registrations', + 'Back up this file, then repair its renderer/platform entries or restore a known-good manifest backup.'); } foreach ($platforms as $platform => $executable) { if (! is_string($platform) || ! is_string($executable)) { - throw new RuntimeException('The existing renderer manifest has invalid registrations; installation was refused.'); + throw $this->createManifestException($manifestFile, 'has invalid registrations', + 'Back up this file, then repair its renderer/platform entries or restore a known-good manifest backup.'); } - $this->assertSafeRelativePath($executable); + try { + $this->assertSafeRelativePath($executable); + } catch (RuntimeException $error) { + throw $this->createManifestException($manifestFile, 'has an unsafe executable path', + 'Back up this file, then restore a known-good manifest or correct the entry to a safe installed-relative executable path.', $error); + } } } return $manifest; } + private function createManifestException(string $path, string $reason, string $remedy, ?Throwable $previous = null): RuntimeException + { + return new RuntimeException(sprintf( + 'The existing renderer manifest "%s" %s; installation was refused. %s Do not delete installed payloads or bypass verification.', + $path, $reason, $remedy, + ), previous: $previous); + } + private function writeManifest(string $installedDirectory, string $renderer, string $platform, string $executable): void { $manifestFile = $installedDirectory . '/manifest.json'; @@ -372,7 +390,10 @@ private function writeManifest(string $installedDirectory, string $renderer, str if (@file_put_contents($temporary, $encoded) === false || ! @rename($temporary, $manifestFile)) { @unlink($temporary); - throw new RuntimeException('The renderer manifest could not be written.'); + throw new RuntimeException(sprintf( + 'The renderer manifest "%s" could not be written. Check available disk space and write permissions for "%s", then retry the verified installation.', + $manifestFile, $installedDirectory, + )); } } diff --git a/src/Support/RendererPreparationProcess.php b/src/Support/RendererPreparationProcess.php new file mode 100644 index 0000000..80e925a --- /dev/null +++ b/src/Support/RendererPreparationProcess.php @@ -0,0 +1,96 @@ + $arguments */ + public function run(array $arguments, string $directory, ?OutputInterface $output = null, float $timeoutSeconds = self::DEFAULT_TIMEOUT_SECONDS): string + { + if (! is_finite($timeoutSeconds) || $timeoutSeconds <= 0) { throw new RuntimeException('Preparation timeout must be positive and finite.'); } + $pipes = []; + $process = @proc_open($arguments, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, $directory, null, ['bypass_shell' => true]); + if (! is_resource($process)) { + throw new RuntimeException('Could not start the renderer preparation tool.'); + } + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $captured = $errors = ''; + $deadline = hrtime(true) / self::NANOSECONDS_PER_SECOND + $timeoutSeconds; + try { + while (! feof($pipes[1]) || ! feof($pipes[2])) { + if (hrtime(true) / self::NANOSECONDS_PER_SECOND >= $deadline) { + throw new RuntimeException('Renderer preparation timed out after ' . $timeoutSeconds . ' seconds.'); + } + $read = array_values(array_filter([$pipes[1], $pipes[2]], static fn ($pipe): bool => ! feof($pipe))); + $write = $except = null; + if (@stream_select($read, $write, $except, 0, self::SELECT_WAIT_MICROSECONDS) === false) { + throw new RuntimeException('Could not read renderer preparation output.'); + } + foreach ($read as $pipe) { + $chunk = fread($pipe, self::READ_CHUNK_BYTES); + if ($chunk === false) { throw new RuntimeException('Renderer preparation output could not be read.'); } + if ($pipe === $pipes[2]) { $errors = substr($errors . $chunk, -self::OUTPUT_LIMIT_BYTES); } + elseif ($output === null) { + $captured .= $chunk; + if (strlen($captured) > self::OUTPUT_LIMIT_BYTES) { throw new RuntimeException('Renderer preparation description exceeds the 64 KiB output limit.'); } + } + $output?->write($chunk, false, OutputInterface::OUTPUT_RAW); + } + } + // A tool may close its pipes and then hang instead of exiting. + do { + $state = proc_get_status($process); + if (! $state['running']) { break; } + if (hrtime(true) / self::NANOSECONDS_PER_SECOND >= $deadline) { + throw new RuntimeException('Renderer preparation timed out after ' . $timeoutSeconds . ' seconds.'); + } + usleep(self::EXIT_POLL_MICROSECONDS); + } while (true); + } catch (Throwable $error) { + $this->terminate($process); + throw $error; + } finally { + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + } + if ($status === -1) { $status = $state['exitcode']; } + if ($status !== 0) { + throw new RuntimeException('Renderer preparation failed (exit ' . $status . '). ' . trim($errors)); + } + return $captured; + } + + /** @param resource $process */ + private function terminate($process): void + { + if (! proc_get_status($process)['running']) { return; } + proc_terminate($process); + $deadline = hrtime(true) / self::NANOSECONDS_PER_SECOND + self::TERMINATION_GRACE_SECONDS; + while (proc_get_status($process)['running']) { + if (hrtime(true) / self::NANOSECONDS_PER_SECOND >= $deadline) { + proc_terminate($process, 9); + return; + } + usleep(self::EXIT_POLL_MICROSECONDS); + } + } +} diff --git a/src/Support/RendererUpdateOffer.php b/src/Support/RendererUpdateOffer.php new file mode 100644 index 0000000..632d414 --- /dev/null +++ b/src/Support/RendererUpdateOffer.php @@ -0,0 +1,77 @@ +): (int|string))|null $prompt Chooses among the offer's options. + */ + public function __construct( + ?SourceRendererUpdateChecker $checker = null, + ?SourceRendererUpdater $updater = null, + ?TerminalInteractivity $terminalInteractivity = null, + ?callable $prompt = null, + ) { + $this->checker = $checker ?? new SourceRendererUpdateChecker(); + $this->updater = $updater ?? new SourceRendererUpdater($this->checker); + $this->terminalInteractivity = $terminalInteractivity ?? new TerminalInteractivity(); + $this->prompt = $prompt === null + ? static fn (string $label, array $options): int|string => select(label: $label, options: $options) + : Closure::fromCallable($prompt); + } + + public function offer(string $workingDirectory, string $rendererId, InputInterface $input, OutputInterface $output): void + { + try { + $update = $this->checker->check($workingDirectory, $rendererId); + if ($update === null || $update->current || $update->skipped) { return; } + $output->writeln('A ' . $rendererId . ' renderer update is available for ' . $update->platform . '.'); + if (! $input->isInteractive() || ! $this->terminalInteractivity->supportsPrompts()) { + $output->writeln('Continuing game launch with the selected renderer. Run `ichiloto renderer:update` to update it.'); + return; + } + $choice = ($this->prompt)('Renderer update available', [ + 'update' => 'Update now', + 'continue' => 'Continue this launch', + 'skip' => 'Skip this version', + ]); + if ($choice === 'update') { + $installed = $this->updater->update($workingDirectory, $rendererId, $output); + $output->writeln($installed ? 'Renderer updated.' : 'Renderer is already current.'); + } elseif ($choice === 'skip') { + $this->updater->skip($update); + $output->writeln('This renderer source version will not be offered again.'); + } elseif ($choice !== 'continue') { + throw new \UnexpectedValueException('The renderer update choice was not recognized.'); + } + } catch (Throwable $error) { + $output->writeln('Renderer update check or update failed: ' + . OutputFormatter::escape($error->getMessage()) . '. Continuing game launch.'); + } + } +} diff --git a/src/Support/SourceRendererUpdate.php b/src/Support/SourceRendererUpdate.php new file mode 100644 index 0000000..963e22d --- /dev/null +++ b/src/Support/SourceRendererUpdate.php @@ -0,0 +1,45 @@ + */ + public function getIdentity(): array + { + return [ + 'version' => 1, + 'source' => $this->source, + 'builder' => $this->builder, + 'renderer' => $this->renderer, + 'platform' => $this->platform, + 'profile' => 'release', + 'fingerprint' => $this->fingerprint, + ]; + } + + public function getReceiptFile(): string + { + return $this->boundary . '/installed/.source-' . $this->renderer . '-' . $this->platform . '.json'; + } + + public function getSkipFile(): string + { + return $this->boundary . '/installed/.source-skip-' . $this->renderer . '-' . $this->platform . '.json'; + } +} diff --git a/src/Support/SourceRendererUpdateChecker.php b/src/Support/SourceRendererUpdateChecker.php new file mode 100644 index 0000000..743e2de --- /dev/null +++ b/src/Support/SourceRendererUpdateChecker.php @@ -0,0 +1,155 @@ +locateEngine = $locateEngine === null ? $this->resolveEngine(...) : Closure::fromCallable($locateEngine); + $this->platform = $platform ?? RendererPackageInstaller::hostPlatform(); + } + + public function check(string $projectDirectory, string $renderer): ?SourceRendererUpdate + { + if ($renderer === 'terminal') { return null; } + if (preg_match('/^[a-z][a-z0-9-]*$/', $renderer) !== 1 + || preg_match('/^[a-z]+-[a-z0-9_]+$/', $this->platform) !== 1) { + throw new RuntimeException('Invalid renderer or platform identity for update checking.'); + } + $engine = ($this->locateEngine)($projectDirectory); + $engine = realpath($engine) ?: $engine; + $vendor = realpath($projectDirectory . '/vendor'); + // Composer --prefer-source is an installed dependency, not a developer workspace. + if ($vendor !== false && ($engine === $vendor || str_starts_with($engine, $vendor . DIRECTORY_SEPARATOR))) { return null; } + $declarationFile = $engine . '/resources/renderers/development.json'; + if (! file_exists($engine . '/.git') || ! is_file($declarationFile)) { return null; } + $declaration = $this->readJson($declarationFile); + if (($declaration['version'] ?? null) !== 1 || ! is_array($declaration['sources'] ?? null)) { + throw new RuntimeException('Unsupported renderer development declaration: ' . $declarationFile); + } + $entry = $declaration['sources'][$renderer] ?? null; + if ($entry === null) { return null; } + if (! is_array($entry) || ! is_string($entry['directory'] ?? null) || ! is_string($entry['builder'] ?? null)) { + throw new RuntimeException('Invalid renderer source declaration: ' . $declarationFile); + } + $source = realpath(dirname($declarationFile) . '/' . $entry['directory']); + if ($source === false || ! is_dir($source) || ! file_exists($source . '/.git')) { + throw new RuntimeException('The development renderer source declared in ' . $declarationFile . ' is unavailable.'); + } + $builder = realpath($source . '/' . $entry['builder']); + if ($builder === false || ! str_starts_with($builder, $source . DIRECTORY_SEPARATOR) + || ! is_file($builder) || ! is_readable($builder)) { + throw new RuntimeException('The declared renderer builder must be a readable file inside ' . $source); + } + $boundary = $this->installer->resolveEngineBoundary($engine); + $description = $this->describe($source, $builder, $renderer, sys_get_temp_dir() . '/ichiloto-renderer-check-' . bin2hex(random_bytes(8))); + $update = new SourceRendererUpdate($renderer, $this->platform, $source, $builder, $boundary, + $description['fingerprint'], false, false); + $current = $this->isCurrent($update); + $skipped = ! $current && $this->matchesIdentity($update->getSkipFile(), $update->getIdentity()); + return new SourceRendererUpdate($renderer, $this->platform, $source, $builder, $boundary, + $description['fingerprint'], $current, $skipped); + } + + /** @return array{fingerprint: string, packageDirectory: string} */ + public function describe(string $source, string $builder, string $renderer, string $out): array + { + $description = json_decode($this->process->run([PHP_BINARY, $builder, '--describe', '--out=' . $out], $source, + timeoutSeconds: $this->probeTimeoutSeconds), true, flags: JSON_THROW_ON_ERROR); + if (! is_array($description) || ($description['renderer'] ?? null) !== $renderer + || ($description['platform'] ?? null) !== $this->platform || ($description['profile'] ?? null) !== 'release' + || ! is_string($description['fingerprint'] ?? null) || preg_match('/^[a-f0-9]{64}$/', $description['fingerprint']) !== 1 + || ! is_string($description['packageDirectory'] ?? null) + || ! str_starts_with($description['packageDirectory'], $out . DIRECTORY_SEPARATOR) + || str_contains($description['packageDirectory'], "\0") + || preg_match('~(?:^|[/\\\\])\.\.(?:[/\\\\]|$)~', $description['packageDirectory'])) { + throw new RuntimeException('The renderer builder returned an invalid update description for ' . $renderer . '/' . $this->platform); + } + return ['fingerprint' => $description['fingerprint'], 'packageDirectory' => $description['packageDirectory']]; + } + + private function resolveEngine(string $project): string + { + $probe = <<<'PHP' +require $argv[1]; +$file = (new ReflectionClass('Ichiloto\Engine\Core\Game'))->getFileName(); +if ($file === false) { throw new RuntimeException('The project Engine has no source file.'); } +echo json_encode(dirname($file, 3), JSON_THROW_ON_ERROR); +PHP; + $root = json_decode($this->process->run([PHP_BINARY, '-r', $probe, $project . '/vendor/autoload.php'], $project, + timeoutSeconds: $this->probeTimeoutSeconds), true, flags: JSON_THROW_ON_ERROR); + if (! is_string($root) || ! is_dir($root)) { throw new RuntimeException('Cannot resolve the Engine loaded by this project.'); } + return realpath($root) ?: $root; + } + + private function isCurrent(SourceRendererUpdate $update): bool + { + $installed = $update->boundary . '/installed'; + if (! $this->matchesIdentity($update->getReceiptFile(), $update->getIdentity()) + || ! is_file($installed . '/manifest.json') || is_link($installed . '/manifest.json')) { return false; } + try { + $receipt = $this->readJson($update->getReceiptFile()); + $manifest = $this->readJson($installed . '/manifest.json'); + } catch (\Throwable) { return false; } + $executable = $receipt['executable'] ?? null; + $files = $receipt['files'] ?? null; + if (($manifest['version'] ?? null) !== 1 || ! is_string($executable) || ! is_array($files) || $files === [] + || ! isset($files[$executable]) || ($manifest['renderers'][$update->renderer][$update->platform] ?? null) !== $executable) { return false; } + $prefix = $update->renderer . '/' . $update->platform . '/'; + foreach ($files as $relative => $hash) { + if (! is_string($relative) || ! str_starts_with($relative, $prefix) || str_contains($relative, "\0") + || str_contains($relative, '\\') || preg_match('~(?:^|/)\.\.(?:/|$)~', $relative) + || ! is_string($hash) || preg_match('/^[a-f0-9]{64}$/', $hash) !== 1) { return false; } + $path = realpath($installed . '/' . $relative); + if ($path === false || ! str_starts_with($path, $installed . DIRECTORY_SEPARATOR) + || ! is_file($path) || ! is_readable($path) || ! hash_equals($hash, (string) hash_file('sha256', $path))) { return false; } + } + $payload = $installed . '/' . rtrim($prefix, '/'); + if (! is_dir($payload)) { return false; } + $entries = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($payload, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST); + foreach ($entries as $entry) { + if ($entry->isLink()) { return false; } + if ($entry->isFile() && ! array_key_exists(substr($entry->getPathname(), strlen($installed) + 1), $files)) { return false; } + } + return is_executable($installed . '/' . $executable); + } + + /** @param array $identity */ + private function matchesIdentity(string $file, array $identity): bool + { + if (! is_file($file) || is_link($file)) { return false; } + try { $saved = $this->readJson($file); } catch (\Throwable) { return false; } + foreach ($identity as $key => $value) { + if (($saved[$key] ?? null) !== $value) { return false; } + } + return true; + } + + /** @return array */ + private function readJson(string $file): array + { + $contents = @file_get_contents($file); + if ($contents === false) { throw new RuntimeException('Cannot read ' . $file); } + $value = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + if (! is_array($value)) { throw new RuntimeException('Expected a JSON object in ' . $file); } + return $value; + } +} diff --git a/src/Support/SourceRendererUpdater.php b/src/Support/SourceRendererUpdater.php new file mode 100644 index 0000000..3b6c068 --- /dev/null +++ b/src/Support/SourceRendererUpdater.php @@ -0,0 +1,160 @@ +checker->check($projectDirectory, $renderer); + if ($candidate === null) { + throw new RuntimeException('No development source is declared for renderer ' . $renderer . ' in this project Engine.'); + } + $installed = $candidate->boundary . '/installed'; + $this->ensureDirectory($installed); + $lockFile = $installed . '/.source-update.lock'; + if (is_link($lockFile)) { throw new RuntimeException('Renderer update lock must not be a symbolic link.'); } + $lock = @fopen($lockFile, 'c'); + if ($lock === false) { throw new RuntimeException('Cannot lock renderer updates in ' . $installed); } + $temporary = null; + try { + if (! is_finite($this->lockTimeoutSeconds) || $this->lockTimeoutSeconds <= 0) { + throw new RuntimeException('Renderer update lock timeout must be positive and finite.'); + } + $deadline = hrtime(true) / self::NANOSECONDS_PER_SECOND + $this->lockTimeoutSeconds; + while (! flock($lock, LOCK_EX | LOCK_NB)) { + if (hrtime(true) / self::NANOSECONDS_PER_SECOND >= $deadline) { + throw new RuntimeException('Renderer update lock timed out. Another update may be in progress.'); + } + usleep(self::LOCK_RETRY_MICROSECONDS); + } + // Recheck after locking: another process may have installed this fingerprint. + $update = $this->checker->check($projectDirectory, $renderer); + if ($update === null) { throw new RuntimeException('The declared renderer source is no longer available.'); } + if ($update->current) { + $this->clearSkip($update); + return false; + } + $temporary = sys_get_temp_dir() . '/ichiloto-renderer-update-' . bin2hex(random_bytes(12)); + if (! @mkdir($temporary, 0700)) { throw new RuntimeException('Cannot create private renderer update directory.'); } + $temporary = realpath($temporary) ?: $temporary; + $description = $this->checker->describe($update->source, $update->builder, $renderer, $temporary); + if ($description['fingerprint'] !== $update->fingerprint) { + throw new RuntimeException('Renderer source changed during update checking; retry the update.'); + } + $output->writeln('Building the ' . $renderer . ' renderer for ' . $update->platform . '…'); + $this->process->run([PHP_BINARY, $update->builder, '--out=' . $temporary], $update->source, + $output, $this->buildTimeoutSeconds); + if ($this->checker->describe($update->source, $update->builder, $renderer, $temporary) !== $description) { + throw new RuntimeException('Renderer source changed during the build; the existing installation was preserved.'); + } + $package = realpath($description['packageDirectory']); + if ($package === false || ! str_starts_with($package, $temporary . DIRECTORY_SEPARATOR) || ! is_dir($package)) { + throw new RuntimeException('The builder did not produce its declared package inside the private output directory.'); + } + $descriptorFile = $package . '/renderer-package.json'; + $descriptorContents = @file_get_contents($descriptorFile); + if ($descriptorContents === false) { throw new RuntimeException('The built package has no readable descriptor.'); } + $descriptor = json_decode($descriptorContents, true, flags: JSON_THROW_ON_ERROR); + if (! is_array($descriptor) || ($descriptor['renderer'] ?? null) !== $renderer + || ($descriptor['platform'] ?? null) !== $update->platform) { + throw new RuntimeException('The built package does not match the selected renderer and host platform.'); + } + $this->installer->install($package, $update->boundary); + $receipt = [...$update->getIdentity(), 'executable' => $descriptor['executable'], 'files' => $descriptor['files']]; + try { + $this->writeJson($update->getReceiptFile(), $receipt); + } catch (\Throwable $error) { + $output->writeln('The renderer was installed, but its update receipt could not be saved: ' + . OutputFormatter::escape($error->getMessage()) . ''); + } + try { + $this->clearSkip($update); + } catch (\Throwable $error) { + $output->writeln('The renderer was installed, but its skipped-version record could not be cleared: ' + . OutputFormatter::escape($error->getMessage()) . ''); + } + return true; + } finally { + if ($temporary !== null && is_dir($temporary)) { + try { $this->removeTemporaryDirectory($temporary); } + catch (\Throwable $error) { + $output->writeln('The temporary renderer package could not be removed: ' + . OutputFormatter::escape($error->getMessage()) . ''); + } + } + flock($lock, LOCK_UN); + fclose($lock); + } + } + + public function skip(SourceRendererUpdate $update): void + { + if ($update->current) { return; } + $this->ensureDirectory($update->boundary . '/installed'); + $this->writeJson($update->getSkipFile(), $update->getIdentity()); + } + + private function clearSkip(SourceRendererUpdate $update): void + { + $skipFile = $update->getSkipFile(); + if ((is_file($skipFile) || is_link($skipFile)) && ! @unlink($skipFile)) { + throw new RuntimeException('The renderer was installed, but its skipped-version record could not be cleared.'); + } + } + + /** @param array $value */ + private function writeJson(string $file, array $value): void + { + $temporary = $file . '.tmp-' . bin2hex(random_bytes(6)); + try { + if (@file_put_contents($temporary, json_encode($value, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n") === false + || ! @rename($temporary, $file)) { + throw new RuntimeException('Cannot save renderer update state in ' . dirname($file)); + } + } finally { + if (is_file($temporary)) { @unlink($temporary); } + } + } + + private function ensureDirectory(string $directory): void + { + if (is_link($directory)) { throw new RuntimeException('Renderer installation directory must not be a symbolic link.'); } + if (! is_dir($directory) && ! @mkdir($directory, 0755, true) && ! is_dir($directory)) { + throw new RuntimeException('Cannot create renderer installation directory: ' . $directory); + } + } + + private function removeTemporaryDirectory(string $directory): void + { + $entries = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($entries as $entry) { + $removed = $entry->isDir() && ! $entry->isLink() + ? @rmdir($entry->getPathname()) : @unlink($entry->getPathname()); + if (! $removed) { throw new RuntimeException('Cannot remove ' . $entry->getPathname()); } + } + if (! @rmdir($directory)) { throw new RuntimeException('Cannot remove ' . $directory); } + } +} diff --git a/src/Support/TerminalSessionChannel.php b/src/Support/TerminalSessionChannel.php new file mode 100644 index 0000000..80e4e85 --- /dev/null +++ b/src/Support/TerminalSessionChannel.php @@ -0,0 +1,76 @@ + $message */ + public function sendMessage(array $message, float $timeout = 1.0): void + { + $encoded = json_encode($message, JSON_THROW_ON_ERROR) . "\n"; + if (strlen($encoded) > self::MAX_MESSAGE_BYTES) { + throw new RuntimeException('Terminal session message exceeds the resource limit.'); + } + $deadline = microtime(true) + $timeout; + while ($encoded !== '') { + $written = @fwrite($this->stream, $encoded); + if ($written === false || $this->isClosed() || microtime(true) >= $deadline) { + throw new RuntimeException('Terminal session lifetime connection closed while writing.'); + } + $encoded = substr($encoded, $written); + if ($written === 0) { + usleep(10000); + } + } + } + + /** @return array|null */ + public function receiveMessage(): ?array + { + $chunk = @fread($this->stream, 8192); + if ($chunk !== false) { + $this->buffer .= $chunk; + } + if (strlen($this->buffer) > self::MAX_MESSAGE_BYTES) { + throw new RuntimeException('Terminal session message exceeds the resource limit.'); + } + $newline = strpos($this->buffer, "\n"); + if ($newline === false) { + return null; + } + $line = substr($this->buffer, 0, $newline); + $this->buffer = substr($this->buffer, $newline + 1); + $message = json_decode($line, true, flags: JSON_THROW_ON_ERROR); + if (! is_array($message)) { + throw new RuntimeException('Invalid terminal session message.'); + } + + return $message; + } + + public function isClosed(): bool + { + return ! is_resource($this->stream) || feof($this->stream); + } + + public function close(): void + { + if (is_resource($this->stream)) { + fclose($this->stream); + } + } +} diff --git a/src/Support/TerminalSessionLauncher.php b/src/Support/TerminalSessionLauncher.php new file mode 100644 index 0000000..5d2c9c8 --- /dev/null +++ b/src/Support/TerminalSessionLauncher.php @@ -0,0 +1,252 @@ + 60) { + throw new RuntimeException('Terminal ready timeout must be between zero and sixty seconds.'); + } + $this->window ??= new TerminalSessionWindow(); + $this->scratchRoot ??= sys_get_temp_dir(); + $this->outputWriter = $outputWriter !== null ? Closure::fromCallable($outputWriter) + : static function (string $text): void { fwrite(STDERR, $text); }; + $this->cancellationRequested = $cancellationRequested !== null ? Closure::fromCallable($cancellationRequested) : static fn (): bool => false; + } + + public function cancel(): void + { + $this->cancelled = true; + } + + public function getFailureReason(): ?string + { + return $this->failureReason; + } + + /** @param list $gameArguments + * @param array $environment + * @return int|null Child exit status, 130/143 on cancellation, null on refusal/start failure. + */ + public function launch(string $workingDirectory, array $gameArguments, array $environment): ?int + { + $this->failureReason = null; + $this->cancelled = false; + $server = null; + $channel = null; + $opener = null; + $scratch = null; + $diagnostic = null; + $diagnosticPath = $environment[self::DIAGNOSTIC_LOG_ENVIRONMENT] ?? null; + $result = null; + $nonce = bin2hex(random_bytes(32)); + $signal = 0; + $handlers = []; + $asyncSignals = null; + try { + $this->validateLaunch($workingDirectory, $gameArguments, $environment); + $reason = $this->window->describeUnavailable(); + if ($reason !== null) { + throw new RuntimeException($reason); + } + $owner = new TerminalSessionOwner(); + $nextOwnerCheck = 0.0; + $server = @stream_socket_server('tcp://127.0.0.1:0', $error, $message); + if (! is_resource($server)) { + throw new RuntimeException('Could not create the local Terminal lifetime rendezvous: ' . $message); + } + stream_set_blocking($server, false); + $address = stream_socket_get_name($server, false); + $deadline = microtime(true) + $this->readyTimeout; + $scratch = new TerminalSessionScratch($this->scratchRoot, $nonce); + if (function_exists('pcntl_signal')) { + $asyncSignals = pcntl_async_signals(true); + foreach ([SIGINT, SIGTERM, SIGHUP] as $number) { + $handlers[$number] = pcntl_signal_get_handler($number); + pcntl_signal($number, function (int $received) use (&$signal): void { + $signal = $received; + $this->cancelled = true; + }); + } + } + if (isset($environment[self::DIAGNOSTIC_LOG_ENVIRONMENT])) { + $diagnostic = @fopen($environment[self::DIAGNOSTIC_LOG_ENVIRONMENT], 'rb'); + if (is_resource($diagnostic)) { + fseek($diagnostic, 0, SEEK_END); + } + } + $opener = $this->window->open([ + PHP_BINARY, '-d', 'memory_limit=512M', '-d', 'register_argc_argv=1', __DIR__ . '/TerminalSessionWorker.php', + (string) $address, $nonce, (string) $deadline, + ], $nonce); + $ready = false; + $authenticated = false; + $candidateDeadline = $deadline; + while (true) { + $ownerDied = false; + if (microtime(true) >= $nextOwnerCheck) { + $ownerDied = ! $owner->isAlive(); + $nextOwnerCheck = microtime(true) + 0.5; + } + if ($this->cancelled || ($this->cancellationRequested)() || $ownerDied) { + $this->cancelled = true; + $result = 128 + ($signal ?: 15); + break; + } + if (! $ready && microtime(true) >= $deadline) { + throw new RuntimeException('Terminal window did not become ready within the bounded startup timeout.'); + } + if ($channel === null) { + $stream = @stream_socket_accept($server, 0); + if (is_resource($stream)) { + $channel = new TerminalSessionChannel($stream); + $candidateDeadline = min($deadline, microtime(true) + 0.5); + } + } + if ($channel !== null) { + $packet = $channel->receiveMessage(); + if (! $authenticated && $packet !== null) { + if (($packet['type'] ?? null) !== 'hello' || ! is_string($packet['nonce'] ?? null) || ! hash_equals($nonce, $packet['nonce'])) { + $channel->close(); + $channel = null; + continue; + } + $authenticated = true; + fclose($server); + $server = null; + $channel->sendMessage([ + 'type' => 'launch', 'directory' => $workingDirectory, 'arguments' => $gameArguments, + 'environment' => $environment, 'scratchRoot' => $this->scratchRoot, + ]); + } elseif ($authenticated && $packet !== null) { + switch ($packet['type'] ?? null) { + case 'ready': + $ready = true; + break; + case 'failed': + throw new RuntimeException((string) ($packet['reason'] ?? 'Terminal game could not start.')); + case 'complete': + if (! is_int($packet['exitCode'] ?? null)) { + throw new RuntimeException('Terminal game returned an invalid exit status.'); + } + $result = $packet['exitCode']; + break 2; + } + } + if (! $authenticated && (microtime(true) >= $candidateDeadline || $channel->isClosed())) { + $channel->close(); + $channel = null; + } elseif ($authenticated && $channel->isClosed()) { + throw new RuntimeException('Owned Terminal session closed before reporting completion.'); + } + } + if (! is_resource($diagnostic) && $diagnosticPath !== null) { + $diagnostic = @fopen($diagnosticPath, 'rb'); + } + $this->forwardDiagnostics($diagnostic); + usleep(20000); + } + } catch (Throwable $exception) { + $this->failureReason = $exception->getMessage(); + ($this->outputWriter)('Terminal session: ' . $this->failureReason . "\n"); + } finally { + // EOF is the cancellation token, including on SIGKILL of this Play process. + $channel?->close(); + if (is_resource($server)) { + fclose($server); + } + if (! is_resource($diagnostic) && $diagnosticPath !== null) { + $diagnostic = @fopen($diagnosticPath, 'rb'); + } + $this->forwardDiagnostics($diagnostic); + if (is_resource($diagnostic)) { + fclose($diagnostic); + } + if ($opener !== null) { + // Owned windows close on every completion, including a game crash. + $stopDeadline = microtime(true) + 2.0; + while ($opener->isRunning() && microtime(true) < $stopDeadline) { + usleep(20000); + } + if ($opener->isRunning()) { + $opener->stop(); + } elseif (! $opener->isRunning()) { + $opener->close(); + } + $this->window->closeOwnedWindow($nonce); + } + $scratch?->remove(); + foreach ($handlers as $number => $handler) { + pcntl_signal($number, $handler); + } + if ($asyncSignals !== null) { + pcntl_async_signals($asyncSignals); + } + } + + if ($result !== null && $result !== 0) { + ($this->outputWriter)('Terminal game exited with status ' . $result . ".\n"); + } + + return $result; + } + + /** @param list $arguments + * @param array $environment + */ + private function validateLaunch(string $directory, array $arguments, array $environment): void + { + if (! is_dir($directory) || realpath($directory) !== $directory) { + throw new RuntimeException('Terminal game directory must be an existing resolved absolute path.'); + } + if (! array_is_list($arguments) || $arguments === [] || $arguments[0] === '') { + throw new RuntimeException('Terminal game requires a nonempty argument vector.'); + } + foreach ($arguments as $argument) { + if (! is_string($argument) || str_contains($argument, "\0")) { + throw new RuntimeException('Terminal game arguments must be NUL-free strings.'); + } + } + foreach ($environment as $key => $value) { + if (! is_string($key) || $key === '' || strpbrk($key, "=\0") !== false || ! is_string($value) || str_contains($value, "\0")) { + throw new RuntimeException('Terminal game environment must contain valid string keys and values.'); + } + } + } + + /** @param resource|false|null $stream */ + private function forwardDiagnostics($stream): void + { + if (is_resource($stream)) { + $text = fread($stream, 65536); + if ($text !== false && $text !== '') { + ($this->outputWriter)($text); + } + } + } +} diff --git a/src/Support/TerminalSessionOwner.php b/src/Support/TerminalSessionOwner.php new file mode 100644 index 0000000..d6a950e --- /dev/null +++ b/src/Support/TerminalSessionOwner.php @@ -0,0 +1,51 @@ +queryWindows = $queryWindows !== null ? Closure::fromCallable($queryWindows) + : static function (int $pid, bool $parent): ?array { + $script = '$p = Get-CimInstance Win32_Process -Filter "ProcessId = ' . $pid . '"; ' + . ($parent ? '$p = Get-CimInstance Win32_Process -Filter ("ProcessId = " + $p.ParentProcessId); ' : '') + . '$p | Select-Object ProcessId, CreationDate | ConvertTo-Json -Compress'; + $output = TerminalSessionProcess::captureOutput([TerminalSessionProcess::getWindowsPowerShell(), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', $script]); + $value = $output !== null ? json_decode($output, true) : null; + + return is_array($value) ? $value : null; + }; + if ($this->platform === 'Windows') { + $parent = ($this->queryWindows)(getmypid(), true); + if (! is_int($parent['ProcessId'] ?? null) || ! is_string($parent['CreationDate'] ?? null)) { + throw new RuntimeException('Cannot establish the owning Windows parent process; refusing an unowned Terminal session.'); + } + $this->parentPid = $parent['ProcessId']; + $this->identity = $parent['CreationDate']; + } else { + $this->parentPid = posix_getppid(); + } + } + + public function isAlive(): bool + { + if ($this->platform !== 'Windows') { + return posix_getppid() === $this->parentPid; + } + $parent = ($this->queryWindows)($this->parentPid, false); + + return ($parent['ProcessId'] ?? null) === $this->parentPid && ($parent['CreationDate'] ?? null) === $this->identity; + } +} diff --git a/src/Support/TerminalSessionProcess.php b/src/Support/TerminalSessionProcess.php new file mode 100644 index 0000000..067246a --- /dev/null +++ b/src/Support/TerminalSessionProcess.php @@ -0,0 +1,222 @@ + pid => process start identity */ + private array $descendants = []; + private ?int $exitCode = null; + private bool $closed = false; + private ?string $rootIdentity = null; + + /** @param resource $process */ + public function __construct(private $process, private string $platform = PHP_OS_FAMILY) + { + } + + /** @param list $arguments + * @param array $descriptors + * @param array|null $environment + * @param array $options + * @return resource|false + */ + public static function start(array $arguments, array $descriptors, ?string $directory = null, ?array $environment = null, array $options = []) + { + $pipes = []; + + return @proc_open($arguments, $descriptors, $pipes, $directory, $environment, ['bypass_shell' => true, ...$options]); + } + + public function isRunning(): bool + { + if ($this->closed) { + return false; + } + $status = proc_get_status($this->process); + if (! $status['running']) { + $this->exitCode ??= $status['exitcode'] >= 0 ? $status['exitcode'] : ($status['signaled'] ? 128 + $status['termsig'] : null); + } + + return $status['running']; + } + + public function getProcessId(): int + { + return proc_get_status($this->process)['pid']; + } + + public function getExitCode(): ?int + { + $this->isRunning(); + + return $this->exitCode; + } + + public function rememberDescendants(): void + { + if ($this->closed) { + return; + } + $table = $this->readProcessTable(); + $root = $this->getProcessId(); + $this->rootIdentity ??= $table[$root]['identity'] ?? null; + $parents = isset($table[$root]) && $table[$root]['identity'] === $this->rootIdentity ? [$root] : []; + do { + $next = []; + foreach ($table as $pid => $entry) { + if (in_array($entry['parent'], $parents, true)) { + $this->descendants[$pid] = $entry['identity']; + $next[] = $pid; + } + } + $parents = $next; + } while ($parents !== []); + } + + public function stop(float $grace = 0.75): void + { + if ($this->closed) { + return; + } + if ($this->platform === 'Windows') { + $this->rememberDescendants(); + $table = $this->readProcessTable(); + foreach (array_reverse($this->descendants, true) as $pid => $identity) { + if (($table[$pid]['identity'] ?? null) === $identity) { + $this->terminateWindowsTree($pid); + } + } + if ($this->isRunning()) { + $this->terminateWindowsTree($this->getProcessId()); + @proc_terminate($this->process); + } + } else { + $this->rememberDescendants(); + $this->signalDescendants(15); + if ($this->isRunning()) { + @proc_terminate($this->process, 15); + } + $deadline = microtime(true) + $grace; + while (microtime(true) < $deadline && ($this->isRunning() || $this->hasDescendants())) { + usleep(20000); + } + $this->signalDescendants(9); + if ($this->isRunning()) { + @proc_terminate($this->process, 9); + } + } + $this->close(); + } + + public function close(): void + { + if (! $this->closed) { + $code = proc_close($this->process); + $this->exitCode ??= $code >= 0 ? $code : null; + $this->closed = true; + } + } + + private function signalDescendants(int $signal): void + { + $table = $this->readProcessTable(); + foreach (array_reverse($this->descendants, true) as $pid => $identity) { + if (($table[$pid]['identity'] ?? null) === $identity) { + @posix_kill($pid, $signal); + } + } + } + + private function hasDescendants(): bool + { + $table = $this->readProcessTable(); + foreach ($this->descendants as $pid => $identity) { + if (($table[$pid]['identity'] ?? null) === $identity) { + return true; + } + } + + return false; + } + + /** @return array */ + private function readProcessTable(): array + { + $table = []; + if ($this->platform === 'Windows') { + // Query locally, but emit only this owned tree (not every Windows process). + $roots = implode(',', [$this->getProcessId(), ...array_keys($this->descendants)]); + $script = '$all = @(Get-CimInstance Win32_Process); $ids = @(' . $roots . '); ' + . 'do { $new = @($all | Where-Object { $ids -contains $_.ParentProcessId -and $ids -notcontains $_.ProcessId }); ' + . '$ids += @($new | ForEach-Object { $_.ProcessId }) } while ($new.Count -gt 0); ' + . 'ConvertTo-Json -Compress -InputObject @($all | Where-Object { $ids -contains $_.ProcessId } | Select-Object ProcessId, ParentProcessId, CreationDate)'; + $output = self::captureOutput([self::getWindowsPowerShell(), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', $script]); + $entries = $output !== null ? json_decode($output, true) : null; + foreach (is_array($entries) ? $entries : [] as $entry) { + if (is_array($entry) && is_int($entry['ProcessId'] ?? null) && is_string($entry['CreationDate'] ?? null)) { + $table[$entry['ProcessId']] = ['parent' => (int) $entry['ParentProcessId'], 'identity' => $entry['CreationDate']]; + } + } + + return $table; + } + $output = self::captureOutput(['/bin/ps', '-axo', 'pid=,ppid=,lstart=']); + foreach (explode("\n", (string) $output) as $line) { + if (preg_match('/^\s*(\d+)\s+(\d+)\s+(.+)$/', $line, $match)) { + $table[(int) $match[1]] = ['parent' => (int) $match[2], 'identity' => $match[3]]; + } + } + + return $table; + } + + public static function getWindowsPowerShell(): string + { + return (getenv('SystemRoot') ?: 'C:\\Windows') . '\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + } + + /** Bounded local system queries; output is bounded and never reaches a GUI terminal. */ + /** @param list $arguments */ + public static function captureOutput(array $arguments): ?string + { + $null = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'; + $pipes = []; + $process = @proc_open($arguments, [0 => ['file', $null, 'r'], 1 => ['pipe', 'w'], 2 => ['file', $null, 'w']], $pipes, options: ['bypass_shell' => true]); + if (! is_resource($process)) { + return null; + } + stream_set_blocking($pipes[1], false); + $deadline = microtime(true) + 2.0; + $output = ''; + $status = proc_get_status($process); + while ($status['running'] && microtime(true) < $deadline) { + // Windows pipes are not reliably nonblocking; read only after process exit. + if (PHP_OS_FAMILY !== 'Windows') { + $output .= (string) fread($pipes[1], 65536); + } + if (strlen($output) > 1048576) { + break; + } + usleep(10000); + $status = proc_get_status($process); + } + if ($status['running']) { + @proc_terminate($process, 9); + } else { + $output .= (string) stream_get_contents($pipes[1], 1048576); + } + fclose($pipes[1]); + proc_close($process); + + return ! $status['running'] && $status['exitcode'] === 0 && strlen($output) <= 1048576 ? trim($output) : null; + } + + private function terminateWindowsTree(int $pid): void + { + self::captureOutput([(getenv('SystemRoot') ?: 'C:\\Windows') . '\\System32\\taskkill.exe', '/PID', (string) $pid, '/T', '/F']); + } +} diff --git a/src/Support/TerminalSessionScratch.php b/src/Support/TerminalSessionScratch.php new file mode 100644 index 0000000..2bd9a32 --- /dev/null +++ b/src/Support/TerminalSessionScratch.php @@ -0,0 +1,60 @@ +path = rtrim($root, '/\\') . DIRECTORY_SEPARATOR . 'ichiloto-terminal-' . $nonce; + } + + public function create(): void + { + if (! @mkdir($this->path, 0700)) { + throw new RuntimeException('Cannot create private terminal session scratch.'); + } + $marker = @fopen($this->path . '/owner', 'x'); + if (! is_resource($marker)) { + @rmdir($this->path); + throw new RuntimeException('Cannot record terminal session scratch ownership.'); + } + try { + @chmod($this->path . '/owner', 0600); + $written = @fwrite($marker, $this->nonce); + } finally { + fclose($marker); + } + if ($written !== strlen($this->nonce)) { + @unlink($this->path . '/owner'); + @rmdir($this->path); + throw new RuntimeException('Cannot write terminal session scratch ownership.'); + } + } + + public function remove(): void + { + // Never recurse into an unrelated directory or follow a substituted link. + if (is_link($this->path) || is_link($this->path . '/owner') + || ! is_file($this->path . '/owner') + || @file_get_contents($this->path . '/owner') !== $this->nonce) { + return; + } + $entries = @scandir($this->path); + if ($entries !== ['.', '..', 'owner']) { + return; + } + @unlink($this->path . '/owner'); + @rmdir($this->path); + } +} diff --git a/src/Support/TerminalSessionWindow.php b/src/Support/TerminalSessionWindow.php new file mode 100644 index 0000000..96e018c --- /dev/null +++ b/src/Support/TerminalSessionWindow.php @@ -0,0 +1,166 @@ +, array, array): (resource|false)|null $commandRunner + * @param callable(string): ?string|null $findExecutable + */ + public function __construct( + private string $platform = PHP_OS_FAMILY, + ?callable $commandRunner = null, + ?callable $findExecutable = null, + ) { + $this->commandRunner = $commandRunner !== null ? Closure::fromCallable($commandRunner) + : static fn (array $arguments, array $descriptors, array $options) => TerminalSessionProcess::start($arguments, $descriptors, options: $options); + $this->findExecutable = $findExecutable !== null ? Closure::fromCallable($findExecutable) : self::findExecutable(...); + } + + public function describeUnavailable(): ?string + { + if (! in_array($this->platform, ['Darwin', 'Linux', 'Windows'], true)) { + return 'Standalone Terminal sessions are unsupported on ' . $this->platform . '.'; + } + if ($this->platform !== 'Windows' && (! function_exists('pcntl_signal') || ! function_exists('posix_kill'))) { + return 'Standalone Terminal sessions require the PHP pcntl and posix extensions on this platform.'; + } + $executable = match ($this->platform) { + 'Darwin' => 'osascript', + 'Linux' => 'xterm', + default => PHP_BINARY, + }; + if (($this->findExecutable)($executable) === null) { + return 'Standalone Terminal session requires an available ' . $executable . ' executable.'; + } + if ($this->platform === 'Windows') { + foreach ([TerminalSessionProcess::getWindowsPowerShell(), (getenv('SystemRoot') ?: 'C:\\Windows') . '\\System32\\taskkill.exe'] as $helper) { + if (($this->findExecutable)($helper) === null) { + return 'Owned Windows Terminal sessions require the local PowerShell and taskkill process-lifetime helpers.'; + } + } + } + if ($this->platform === 'Linux' && (getenv('DISPLAY') ?: '') === '') { + return 'xterm requires an X11 display (including XWayland).'; + } + + return null; + } + + /** @param list $workerArguments */ + public function open(array $workerArguments, string $nonce): TerminalSessionProcess + { + $reason = $this->describeUnavailable(); + if ($reason !== null) { + throw new RuntimeException($reason); + } + $command = $this->buildOpenCommand($workerArguments, $nonce); + $null = $this->platform === 'Windows' ? 'NUL' : '/dev/null'; + // Windows worker reopens CONIN$/CONOUT$ rather than using inherited GUI handles. + $descriptors = $this->platform === 'Windows' ? [] : [0 => ['file', $null, 'r'], 1 => ['file', $null, 'w'], 2 => ['file', $null, 'w']]; + $process = ($this->commandRunner)($command['arguments'], $descriptors, $command['options']); + if (! is_resource($process)) { + throw new RuntimeException('Could not open the owned Terminal window.'); + } + + return new TerminalSessionProcess($process, $this->platform); + } + + /** @param list $workerArguments + * @return array{arguments: list, options: array} + */ + public function buildOpenCommand(array $workerArguments, string $nonce): array + { + if (! preg_match('/^[a-f0-9]{64}$/D', $nonce)) { + throw new RuntimeException('Invalid terminal window ownership token.'); + } + $title = 'Ichiloto Play ' . $nonce; + return match ($this->platform) { + 'Darwin' => [ + 'arguments' => [($this->findExecutable)('osascript') ?? '/usr/bin/osascript', '-e', $this->buildMacOpenScript($workerArguments, $title)], + 'options' => [], + ], + 'Linux' => ['arguments' => [($this->findExecutable)('xterm') ?? 'xterm', '-T', $title, '-e', ...$workerArguments], 'options' => []], + 'Windows' => ['arguments' => $workerArguments, 'options' => ['create_new_console' => true, 'create_process_group' => true]], + default => throw new RuntimeException('Unsupported Terminal platform.'), + }; + } + + public function closeOwnedWindow(string $nonce, bool $wait = true): void + { + if (! preg_match('/^[a-f0-9]{64}$/D', $nonce)) { + throw new RuntimeException('Invalid terminal window ownership token.'); + } + if ($this->platform !== 'Darwin') { + return; // xterm and Windows close when their own worker exits. + } + $process = ($this->commandRunner)([($this->findExecutable)('osascript') ?? '/usr/bin/osascript', '-e', $this->buildMacCloseScript($nonce)], + [0 => ['file', '/dev/null', 'r'], 1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']], []); + if (is_resource($process) && $wait) { + $owned = new TerminalSessionProcess($process, $this->platform); + $deadline = microtime(true) + 3.0; + while ($owned->isRunning() && microtime(true) < $deadline) { + usleep(20000); + } + $owned->isRunning() ? $owned->stop() : $owned->close(); + } + // A worker schedules this bounded closer just before exiting on owner death. + } + + /** @param list $arguments */ + private function buildMacOpenScript(array $arguments, string $title): string + { + $command = 'exec ' . implode(' ', array_map(self::quotePosixArgument(...), $arguments)); + + return 'tell application "Terminal"' . "\n" + . 'set ownedTab to do script ' . self::quoteAppleScript($command) . "\n" + . 'set custom title of ownedTab to ' . self::quoteAppleScript($title) . "\n" + . "activate\nend tell"; + } + + private function buildMacCloseScript(string $nonce): string + { + $title = self::quoteAppleScript('Ichiloto Play ' . $nonce); + + return "repeat 20 times\nif application \"Terminal\" is not running then return\n" + . "tell application \"Terminal\"\nrepeat with ownedWindow in windows\n" + . "if (count of tabs of ownedWindow) is 1 then\n" + . "if custom title of tab 1 of ownedWindow is {$title} then\n" + . "if busy of tab 1 of ownedWindow is false then\nclose ownedWindow saving no\nreturn\nend if\nend if\nend if\n" + . "end repeat\nend tell\ndelay 0.1\nend repeat"; + } + + private static function quotePosixArgument(string $argument): string + { + return "'" . str_replace("'", "'\\''", $argument) . "'"; + } + + private static function quoteAppleScript(string $value): string + { + return '"' . str_replace(['\\', '"', "\r", "\n"], ['\\\\', '\\"', '\\r', '\\n'], $value) . '"'; + } + + private static function findExecutable(string $name): ?string + { + if (is_file($name) && is_executable($name)) { + return $name; + } + foreach (explode(PATH_SEPARATOR, getenv('PATH') ?: '') as $directory) { + if ($directory !== '' && is_file($directory . DIRECTORY_SEPARATOR . $name) && is_executable($directory . DIRECTORY_SEPARATOR . $name)) { + return $directory . DIRECTORY_SEPARATOR . $name; + } + } + + return null; + } +} diff --git a/src/Support/TerminalSessionWorker.php b/src/Support/TerminalSessionWorker.php new file mode 100644 index 0000000..d4ac63d --- /dev/null +++ b/src/Support/TerminalSessionWorker.php @@ -0,0 +1,166 @@ + $arguments */ + public static function run(array $arguments, ?TerminalSessionWindow $window = null): int + { + $window ??= new TerminalSessionWindow(); + [$address, $nonce, $expires] = array_pad($arguments, 3, ''); + if (! preg_match('/^127\.0\.0\.1:\d+$/D', $address) || ! preg_match('/^[a-f0-9]{64}$/D', $nonce) + || ! is_numeric($expires) || (float) $expires <= microtime(true)) { + if (preg_match('/^[a-f0-9]{64}$/D', $nonce)) { + $window->closeOwnedWindow($nonce, wait: false); + } + return 125; + } + $channel = null; + $game = null; + $scratch = null; + $code = 125; + $cancelled = false; + $cancelSignal = 0; + $handlers = []; + $asyncSignals = null; + $input = STDIN; + $output = STDOUT; + $errors = STDERR; + $diagnosticErrors = null; + try { + // PHP's CREATE_NEW_CONSOLE still supplies the parent's standard handles. + // Bind explicitly to this console, never the GUI's inherited log handles. + if (PHP_OS_FAMILY === 'Windows') { + $input = @fopen('CONIN$', 'rb'); + $output = @fopen('CONOUT$', 'wb'); + $errors = @fopen('CONOUT$', 'wb'); + if (! is_resource($input) || ! is_resource($output) || ! is_resource($errors)) { + throw new RuntimeException('The owned Windows console did not supply real terminal streams.'); + } + } + $stream = @stream_socket_client('tcp://' . $address, $error, $message, min(1.0, (float) $expires - microtime(true))); + if (! is_resource($stream)) { + throw new RuntimeException('The owning Play process is no longer available.'); + } + $channel = new TerminalSessionChannel($stream); + $channel->sendMessage(['type' => 'hello', 'nonce' => $nonce]); + $launch = null; + while ($launch === null && ! $channel->isClosed() && microtime(true) < (float) $expires) { + $launch = $channel->receiveMessage(); + usleep(10000); + } + if (($launch['type'] ?? null) !== 'launch' || $channel->isClosed() || microtime(true) >= (float) $expires) { + throw new RuntimeException('The owned Terminal launch expired before authorization.'); + } + if (function_exists('pcntl_signal')) { + $asyncSignals = pcntl_async_signals(true); + foreach ([SIGINT, SIGTERM, SIGHUP] as $number) { + $handlers[$number] = pcntl_signal_get_handler($number); + pcntl_signal($number, static function (int $received) use (&$cancelled, &$cancelSignal): void { + $cancelled = true; + $cancelSignal = $received; + }); + } + } + $scratch = new TerminalSessionScratch($launch['scratchRoot'], $nonce); + $scratch->create(); + // Never start a game after a disconnected/expired startup rendezvous. + $channel->receiveMessage(); + if ($cancelled || $channel->isClosed() || microtime(true) >= (float) $expires) { + throw new RuntimeException('The owning Play process cancelled before the game started.'); + } + $executable = $launch['arguments'][0]; + if ((str_contains($executable, '/') || str_contains($executable, '\\')) && (! is_file($executable) || ! is_executable($executable))) { + throw new RuntimeException('Terminal game executable is unavailable: ' . $executable); + } + $diagnosticPath = $launch['environment']['ICHILOTO_TERMINAL_DIAGNOSTIC_LOG'] ?? null; + if ($diagnosticPath !== null) { + $diagnosticErrors = @fopen($diagnosticPath, 'ab'); + if (!is_resource($diagnosticErrors)) { + throw new RuntimeException('Could not open the Terminal game diagnostic log.'); + } + $errors = $diagnosticErrors; + } + $process = TerminalSessionProcess::start($launch['arguments'], [0 => $input, 1 => $output, 2 => $errors], $launch['directory'], $launch['environment']); + if (! is_resource($process)) { + throw new RuntimeException('Could not start the game in its owned Terminal.'); + } + $game = new TerminalSessionProcess($process); + $channel->sendMessage(['type' => 'ready']); + $nextSnapshot = 0.0; + while ($game->isRunning()) { + $packet = $channel->receiveMessage(); + if ($cancelled || $channel->isClosed() || ($packet['type'] ?? null) === 'cancel') { + $cancelled = true; + $code = 143; + break; + } + if (microtime(true) >= $nextSnapshot) { + $game->rememberDescendants(); + $nextSnapshot = microtime(true) + 0.2; + } + usleep(20000); + } + if (! $cancelled) { + $code = $game->getExitCode() ?? 125; + } else { + $code = 128 + ($cancelSignal ?: 15); + } + // Reap residual owned consumers before telling the editor to remove its overlay. + $game->stop(); + $game = null; + $scratch->remove(); + $scratch = null; + if (! $channel->isClosed()) { + $channel->sendMessage(['type' => 'complete', 'exitCode' => $code]); + } + } catch (Throwable $exception) { + fwrite(STDERR, 'Terminal session: ' . $exception->getMessage() . "\n"); + if ($channel !== null && ! $channel->isClosed()) { + try { + $channel->sendMessage(['type' => 'failed', 'reason' => $exception->getMessage()]); + } catch (Throwable) { + } + } + } finally { + $game?->stop(); + $scratch?->remove(); + $channel?->close(); + if (is_resource($diagnosticErrors)) { + fclose($diagnosticErrors); + } + } + + // No crash prompt in editor-owned mode. Logs, not a surviving window, + // preserve the diagnosis; this also covers abrupt owner death. + $window->closeOwnedWindow($nonce, wait: false); + foreach ($handlers as $number => $handler) { + pcntl_signal($number, $handler); + } + if ($asyncSignals !== null) { + pcntl_async_signals($asyncSignals); + } + + return $code; + } +} + +if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { + if (! isset($_SERVER['argv']) || ! is_array($_SERVER['argv'])) { + fwrite(STDERR, "Terminal worker requires CLI argument support.\n"); + exit(125); + } + exit(TerminalSessionWorker::run(array_slice($_SERVER['argv'], 1))); +} diff --git a/src/Upgrade/GitWorkingTree.php b/src/Upgrade/GitWorkingTree.php new file mode 100644 index 0000000..1165f66 --- /dev/null +++ b/src/Upgrade/GitWorkingTree.php @@ -0,0 +1,58 @@ +|null + */ + public function getUncommittedChanges(string $directory): ?array + { + $inside = $this->runGit($directory, ['rev-parse', '--is-inside-work-tree']); + + if ($inside === null || trim($inside) !== 'true') { + return null; + } + + $status = $this->runGit($directory, ['status', '--porcelain', '--untracked-files=all', '--', '.']); + + if ($status === null) { + return null; + } + + return array_values(array_filter(explode("\n", $status), static fn(string $line): bool => trim($line) !== '')); + } + + /** @param list $arguments */ + private function runGit(string $directory, array $arguments): ?string + { + $pipes = []; + $process = @proc_open( + ['git', '-C', $directory, ...$arguments], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + + if (! is_resource($process)) { + return null; + } + + fclose($pipes[0]); + $output = (string) stream_get_contents($pipes[1]); + stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return proc_close($process) === 0 ? $output : null; + } +} diff --git a/src/Upgrade/ProjectConfigFile.php b/src/Upgrade/ProjectConfigFile.php new file mode 100644 index 0000000..cf24557 --- /dev/null +++ b/src/Upgrade/ProjectConfigFile.php @@ -0,0 +1,163 @@ + */ + public static function readSettings(string $path): array + { + try { + $data = json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new RuntimeException(sprintf('Unable to parse the project configuration at %s: %s', $path, $exception->getMessage()), previous: $exception); + } + + if (! is_array($data) || array_is_list($data)) { + throw new RuntimeException(sprintf('The project configuration at %s must contain a JSON object.', $path)); + } + + return $data; + } + + public static function getFormatVersion(string $path): int + { + return ProjectFormat::getVersion(self::readSettings($path)[ProjectFormat::KEY] ?? null); + } + + /** Records a format version in the file. */ + public static function setFormatVersion(string $path, int $version): void + { + new ProjectFileWriter()->writeAtomically($path, self::renderFormatVersion((string) file_get_contents($path), $version)); + } + + /** Returns the configuration source with the format version set, changing nothing else. */ + public static function renderFormatVersion(string $source, int $version): string + { + $settings = json_decode($source, true); + + if (! is_array($settings) || array_is_list($settings)) { + throw new RuntimeException('The project configuration must contain a JSON object.'); + } + + $span = self::findTopLevelValue($source, ProjectFormat::KEY); + + if ($span !== null) { + $updated = substr($source, 0, $span[0]) . $version . substr($source, $span[1]); + } else { + $updated = self::appendMember($source, ProjectFormat::KEY, $version); + } + + $settings[ProjectFormat::KEY] = $version; + + if (json_decode($updated, true) !== $settings) { + throw new RuntimeException('The format version could not be recorded without changing other settings.'); + } + + return $updated; + } + + /** + * The byte span of a top-level member's value. + * + * @return array{int, int}|null + */ + private static function findTopLevelValue(string $source, string $key): ?array + { + $depth = 0; + $length = strlen($source); + $expectValueFor = null; + + for ($offset = 0; $offset < $length; $offset++) { + $character = $source[$offset]; + + if ($character === '"') { + $end = self::findStringEnd($source, $offset); + $string = json_decode(substr($source, $offset, $end - $offset + 1)); + + if ($depth === 1 && $string === $key) { + $expectValueFor = $end + 1; + } + + $offset = $end; + continue; + } + + if ($character === ':' && $expectValueFor !== null && $depth === 1) { + $start = $offset + 1; + + while ($start < $length && ctype_space($source[$start])) { + $start++; + } + + $end = $start; + + while ($end < $length && ! in_array($source[$end], [',', '}', ']'], true) && ! ctype_space($source[$end])) { + $end++; + } + + return [$start, $end]; + } + + if ($character === '{' || $character === '[') { + $depth++; + } elseif ($character === '}' || $character === ']') { + $depth--; + } + + if ($character === ',') { + $expectValueFor = null; + } + } + + return null; + } + + private static function findStringEnd(string $source, int $start): int + { + $length = strlen($source); + + for ($offset = $start + 1; $offset < $length; $offset++) { + if ($source[$offset] === '\\') { + $offset++; + continue; + } + + if ($source[$offset] === '"') { + return $offset; + } + } + + throw new RuntimeException('The project configuration has an unterminated string.'); + } + + private static function appendMember(string $source, string $key, int $value): string + { + $closing = strrpos($source, '}'); + + if ($closing === false) { + throw new RuntimeException('The project configuration must contain a JSON object.'); + } + + $before = rtrim(substr($source, 0, $closing)); + $indent = preg_match('/\{\s*\n([ \t]+)"/', $source, $matches) === 1 ? $matches[1] : ' '; + $member = json_encode($key) . ': ' . $value; + + if (str_ends_with($before, '{')) { + return $before . "\n" . $indent . $member . "\n" . substr($source, $closing); + } + + return $before . ",\n" . $indent . $member . "\n" . substr($source, $closing); + } +} diff --git a/src/Upgrade/ProjectFileWriter.php b/src/Upgrade/ProjectFileWriter.php new file mode 100644 index 0000000..bc8c606 --- /dev/null +++ b/src/Upgrade/ProjectFileWriter.php @@ -0,0 +1,41 @@ + Steps by target version. */ + private array $steps; + /** The format the chain upgrades projects to. */ + public int $currentVersion; + + /** + * @param list $steps One step per version, from 1 to the current format. + * @param int $currentVersion The format the chain must reach. + */ + public function __construct(array $steps, int $currentVersion) + { + $this->currentVersion = $currentVersion; + $byVersion = []; + + foreach ($steps as $step) { + $byVersion[$step->getTargetVersion()] = $step; + } + + ksort($byVersion); + + if (array_keys($byVersion) !== range(1, $currentVersion)) { + throw new InvalidArgumentException(sprintf('The upgrade chain needs exactly one step for each format from 1 to %d.', $currentVersion)); + } + + $this->steps = $byVersion; + } + + /** The registered steps, in order. A new format adds its step here. */ + public static function createDefault(): self + { + return new self([ + new SaveMetadataStep(), + ], SaveMetadataStep::VERSION); + } + + public function getRecordedVersion(ProjectUpgradeContext $context): int + { + return ProjectConfigFile::getFormatVersion($context->getPath(ProjectUpgradeContext::CONFIG_FILENAME)); + } + + /** @return list The steps a project at a version still needs. */ + public function getPendingSteps(int $version): array + { + return array_values(array_filter( + $this->steps, + static fn(ProjectUpgradeStepInterface $step): bool => $step->getTargetVersion() > $version, + )); + } + + /** + * Plans every pending step against the project as it is now, for the + * person to read before anything is written. + * + * @return list + */ + public function createPlans(ProjectUpgradeContext $context): array + { + return array_map( + static fn(ProjectUpgradeStepInterface $step): ProjectUpgradePlan => $step->createPlan($context), + $this->getPendingSteps($this->getRecordedVersion($context)), + ); + } + + /** + * Runs every pending step. Each is planned again just before it is + * applied, so it reads what the previous steps wrote. + * + * @return list + */ + public function runPendingSteps(ProjectUpgradeContext $context): array + { + $reports = []; + $configPath = $context->getPath(ProjectUpgradeContext::CONFIG_FILENAME); + + foreach ($this->getPendingSteps($this->getRecordedVersion($context)) as $step) { + $reports[] = $step->applyPlan($context, $step->createPlan($context)); + ProjectConfigFile::setFormatVersion($configPath, $step->getTargetVersion()); + } + + return $reports; + } +} diff --git a/src/Upgrade/ProjectUpgradeContext.php b/src/Upgrade/ProjectUpgradeContext.php new file mode 100644 index 0000000..51a1f43 --- /dev/null +++ b/src/Upgrade/ProjectUpgradeContext.php @@ -0,0 +1,52 @@ +root = $root; + } + + /** The absolute path of a project-relative path written with forward slashes. */ + public function getPath(string $relativePath): string + { + return $this->root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, ltrim($relativePath, '/')); + } + + /** The project-relative path, with forward slashes, of a path inside the project. */ + public function getRelativePath(string $path): string + { + $prefix = $this->root . DIRECTORY_SEPARATOR; + + if (str_starts_with($path, $prefix)) { + $path = substr($path, strlen($prefix)); + } + + return str_replace(DIRECTORY_SEPARATOR, '/', $path); + } +} diff --git a/src/Upgrade/ProjectUpgradeFollowUp.php b/src/Upgrade/ProjectUpgradeFollowUp.php new file mode 100644 index 0000000..1b01f45 --- /dev/null +++ b/src/Upgrade/ProjectUpgradeFollowUp.php @@ -0,0 +1,21 @@ + $changes One human line per pending change, with counts. + * @param array $writes New contents by absolute path. + * @param list $followUps Items for a person. + */ + public function __construct( + public int $targetVersion, + public string $title, + public array $changes, + public array $writes = [], + public array $followUps = [], + ) { + } +} diff --git a/src/Upgrade/ProjectUpgradeReport.php b/src/Upgrade/ProjectUpgradeReport.php new file mode 100644 index 0000000..ed704b7 --- /dev/null +++ b/src/Upgrade/ProjectUpgradeReport.php @@ -0,0 +1,43 @@ + $changes The changes that were made. + * @param list $writtenPaths Project-relative paths the step wrote. + * @param list $followUps Items for a person. + */ + public function __construct( + public int $targetVersion, + public string $title, + public array $changes, + public array $writtenPaths, + public array $followUps, + ) { + } + + /** Applies a plan's writes and returns the report it describes. */ + public static function applyPlan(ProjectUpgradeContext $context, ProjectUpgradePlan $plan): self + { + $writer = new ProjectFileWriter(); + + foreach ($plan->writes as $path => $contents) { + $writer->writeAtomically($path, $contents); + } + + return new self( + $plan->targetVersion, + $plan->title, + $plan->changes, + array_map($context->getRelativePath(...), array_keys($plan->writes)), + $plan->followUps, + ); + } +} diff --git a/src/Upgrade/ProjectUpgradeStepInterface.php b/src/Upgrade/ProjectUpgradeStepInterface.php new file mode 100644 index 0000000..2aed28c --- /dev/null +++ b/src/Upgrade/ProjectUpgradeStepInterface.php @@ -0,0 +1,26 @@ +getPath(ProjectUpgradeContext::CONFIG_FILENAME); + $config = ProjectConfigFile::readSettings($configPath); + $existingId = trim(is_string($config['id'] ?? null) ? $config['id'] : ''); + $requestedId = trim((string) $context->requestedId); + + if ($existingId !== '' && $requestedId !== '' && $requestedId !== $existingId) { + throw new RuntimeException(sprintf( + 'The project already has save identity "%s"; it cannot be replaced with "%s".', + $existingId, + $requestedId, + )); + } + + $projectId = $existingId !== '' + ? $existingId + : ($requestedId !== '' ? $this->assertProjectId($requestedId) : $this->deriveProjectId($context->root, $config)); + $manifestPath = $context->getPath(self::MANIFEST_PATH); + + if (file_exists($manifestPath) && ! is_file($manifestPath)) { + throw new RuntimeException("The save compatibility manifest path is not a file: {$manifestPath}"); + } + + $changes = []; + $writes = []; + + if ($existingId === '') { + $changes[] = sprintf('Add stable project id %s to ichiloto.json.', $projectId); + $writes[$configPath] = json_encode( + ['id' => $projectId] + $config, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + ) . PHP_EOL; + } + + if (! is_file($manifestPath)) { + $changes[] = sprintf('Create %s at legacy content version 0.', self::MANIFEST_PATH); + $writes[$manifestPath] = SaveCompatibilityMetadata::renderBaseline(); + } + + if ($changes === []) { + $changes[] = 'Save metadata is already present; nothing changes.'; + } + + return new ProjectUpgradePlan( + self::VERSION, + ProjectFormat::CHANGES[self::VERSION], + $changes, + $writes, + [new ProjectUpgradeFollowUp('Save identity', sprintf('Keep project id %s unchanged once saves exist.', $projectId))], + ); + } + + public function applyPlan(ProjectUpgradeContext $context, ProjectUpgradePlan $plan): ProjectUpgradeReport + { + return ProjectUpgradeReport::applyPlan($context, $plan); + } + + /** @param array $config */ + private function deriveProjectId(string $projectRoot, array $config): string + { + $composerPath = $projectRoot . DIRECTORY_SEPARATOR . 'composer.json'; + + if (is_file($composerPath)) { + try { + $composer = ProjectConfigFile::readSettings($composerPath); + $composerName = trim(is_string($composer['name'] ?? null) ? $composer['name'] : ''); + + if ($composerName !== '' && $this->isCanonicalProjectId($composerName)) { + return $composerName; + } + } catch (RuntimeException) { + // A legacy project's unrelated Composer problem should not + // prevent deriving an identity from its Ichiloto metadata. + } + } + + $projectName = trim(is_string($config['name'] ?? null) ? $config['name'] : ''); + $slug = $this->slugify($projectName !== '' ? $projectName : basename($projectRoot)); + + if ($slug === '') { + throw new RuntimeException('A stable project id could not be derived. Pass one with --id=vendor/project.'); + } + + return 'ichiloto/' . $slug; + } + + private function assertProjectId(string $projectId): string + { + if (! $this->isCanonicalProjectId($projectId)) { + throw new RuntimeException('The project id must use stable lowercase vendor/project notation.'); + } + + return $projectId; + } + + private function isCanonicalProjectId(string $projectId): bool + { + return preg_match('/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/', $projectId) === 1; + } + + private function slugify(string $value): string + { + $slug = preg_replace('/[^A-Za-z0-9]+/', '-', trim($value)) ?? ''; + + return strtolower(trim($slug, '-')); + } +} diff --git a/src/Upgrade/UpgradeReportDocument.php b/src/Upgrade/UpgradeReportDocument.php new file mode 100644 index 0000000..00a638f --- /dev/null +++ b/src/Upgrade/UpgradeReportDocument.php @@ -0,0 +1,90 @@ + $reports */ + public function __construct( + private int $fromVersion, + private int $toVersion, + private array $reports, + ) { + } + + /** + * The follow-up items grouped by step and section, blocking sections first. + * + * @return array>> Items by section, by step heading. + */ + public function getFollowUps(): array + { + $steps = []; + + foreach ($this->reports as $report) { + $blocking = []; + $other = []; + + foreach ($report->followUps as $item) { + if ($item->blocking) { + $blocking[$item->section][] = $item->message; + } else { + $other[$item->section][] = $item->message; + } + } + + $steps[$this->getStepHeading($report)] = $blocking + $other; + } + + return $steps; + } + + public function renderMarkdown(): string + { + $lines = [ + '# Ichiloto upgrade report', + '', + sprintf('This project was upgraded from format %d to format %d. Work through the items below, then run `ichiloto validate`.', $this->fromVersion, $this->toVersion), + ]; + $followUps = $this->getFollowUps(); + + foreach ($this->reports as $report) { + $heading = $this->getStepHeading($report); + $lines[] = ''; + $lines[] = "## {$heading}"; + $lines[] = ''; + $lines[] = 'Changed:'; + $lines[] = ''; + + foreach ($report->changes as $change) { + $lines[] = "- {$change}"; + } + + foreach ($followUps[$heading] ?? [] as $section => $items) { + $lines[] = ''; + $lines[] = "### {$section}"; + $lines[] = ''; + + foreach ($items as $item) { + $lines[] = "- {$item}"; + } + } + } + + return implode("\n", $lines) . "\n"; + } + + private function getStepHeading(ProjectUpgradeReport $report): string + { + return sprintf('Format %d: %s', $report->targetVersion, $report->title); + } +} diff --git a/tests/battle-member-loadouts.php b/tests/battle-member-loadouts.php new file mode 100644 index 0000000..b3b48df --- /dev/null +++ b/tests/battle-member-loadouts.php @@ -0,0 +1,58 @@ +getMessage(); + } + + return ''; +} + +$plain = BattleMemberOption::parse('Kaelion:20,Weapon=Iron Sword'); +assertLoadout($plain->commands === null && $plain->skills === [] && $plain->summons === [] + && $plain->equipment === ['Weapon' => 'Iron Sword'], + 'a member without a loadout keeps its normal commands and gains nothing'); + +$liora = BattleMemberOption::parse('Liora:20, Weapon=Wooden Staff, Commands=attack|magic|summon|item, Skills=Burn I | Heal I, summons=ifrit'); +assertLoadout($liora->actor === 'Liora' && $liora->level === 20 + && $liora->equipment === ['Weapon' => 'Wooden Staff'] + && $liora->commands === ['attack', 'magic', 'summon', 'item'] + && $liora->skills === ['Burn I', 'Heal I'] + && $liora->summons === ['ifrit'], + 'Commands, Skills and Summons are read as lists, in any case, and are not equipment slots'); + +assertLoadout(str_contains(loadoutFailure('Liora,Commands='), 'Commands needs one or more names separated by |'), + 'an empty list is refused, not read as no commands'); +assertLoadout(str_contains(loadoutFailure('Liora,Skills=Burn I||Heal I'), 'Skills needs one or more names'), + 'an empty name in a list is refused'); +assertLoadout(str_contains(loadoutFailure('Liora,Skills=Burn I|Burn I'), 'Skills names Burn I more than once'), + 'a repeated name is refused'); +assertLoadout(str_contains(loadoutFailure('Liora,Summons=ifrit,Summons=torro'), 'Summons is given more than once'), + 'a loadout key given twice is refused'); +assertLoadout(str_contains(loadoutFailure('Liora,Commands'), 'loadouts as Commands=, Skills= or Summons='), + 'a key without = says how loadouts are written'); + +fwrite(STDOUT, "PASS: --member reads commands, skills and summons as test loadouts.\n"); diff --git a/tests/battle-members.php b/tests/battle-members.php new file mode 100644 index 0000000..6f6f967 --- /dev/null +++ b/tests/battle-members.php @@ -0,0 +1,204 @@ +setup = $setup; + } +} + +function assertBattleMembers(bool $condition, string $message): void +{ + if (! $condition) { + fwrite(STDERR, "FAIL: {$message}\n"); + exit(1); + } +} + +/** @param list $members @return array{0: SetupRecordingBattleCommand, 1: int, 2: string} */ +function runBattleMembers(string $project, array $members, array $options = []): array +{ + // Each run reads its project afresh, as a separate ichiloto battle would. + new ReflectionProperty(ConfigStore::class, 'store')->setValue(null, []); + $registry = new RendererRegistry(); + $command = new SetupRecordingBattleCommand( + rendererRegistry: $registry, + rendererSelector: new RendererSelector($registry), + terminalInteractivity: new TerminalInteractivity(static fn (): bool => false, static fn (): bool => false), + rendererUpdateChecker: new SourceRendererUpdateChecker(locateEngine: static fn (string $root): string => $root), + ); + $application = new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->addCommand($command); + $tester = new ApplicationTester($application); + // A renderer is for playing; --runs refuses one. + $renderer = isset($options['--runs']) ? [] : ['--renderer' => 'terminal']; + $status = $tester->run(['command' => 'battle', '--directory' => $project, ...$renderer, + '--member' => $members, ...$options], ['interactive' => false, 'decorated' => false]); + + return [$command, $status, $tester->getDisplay()]; +} + +$project = writeBattleTestProject(); +writeBattleLoadoutSources($project); + +try { + [$command, $status] = runBattleMembers($project, []); + $members = array_map(static fn($member): array => [$member->actorId, $member->level], $command->setup?->members ?? []); + assertBattleMembers($status === Command::SUCCESS && $members === [['hero', 1], ['mage', 1]], + 'without --member, the starting party is set up'); + + [$command, $status] = runBattleMembers($project, ['Hero:20,Weapon=Iron Sword', 'mage']); + $hero = $command->setup?->members[0]; + assertBattleMembers($status === Command::SUCCESS && $hero?->actorId === 'hero' && $hero->level === 20 + && $hero->equipment === ['Weapon' => 'equipment.iron-sword'] && $command->setup?->members[1]->level === 1, + '--member sets each actor, level and equipment, by name or id'); + + [$command, $status, $display] = runBattleMembers($project, ['ghost', 'Hero:20,Body=Iron Sword,Cape=Potion', 'mage:x']); + assertBattleMembers($status === Command::INVALID && $command->setup === null + && str_contains($display, 'the level must be a whole number') + && str_contains($display, '--member ghost: the project has no such actor (its actors: hero, mage).') + && str_contains($display, 'Iron Sword does not go in the Body slot.') + && str_contains($display, 'has no Cape slot'), + 'every problem with the members is named, and no battle starts'); + + [, $status, $display] = runBattleMembers($project, ['hero', 'hero', 'hero', 'hero', 'hero']); + assertBattleMembers($status === Command::INVALID && str_contains($display, 'at most 4 members'), + 'more members than a party holds are refused'); + + if (! method_exists(\Ichiloto\Engine\Scenes\Arena\BattleTestMember::class, 'withCommands')) { + [, $status, $display] = runBattleMembers($project, ['Hero,Commands=attack|summon']); + assertBattleMembers($status === Command::INVALID + && str_contains($display, "this project's engine cannot set a member's commands, skills or summons; update its engine."), + 'an engine without test loadouts says so instead of ignoring them'); + } else { + [$command, $status] = runBattleMembers($project, ['Hero:5,Commands=skill|Magic|summon,Skills=Test Strike|Test Flame,Summons=test-call', 'mage']); + $hero = $command->setup?->members[0]; + assertBattleMembers($status === Command::SUCCESS + && array_map(static fn($type): string => $type->value, $hero?->commands ?? []) === ['skill', 'magic', 'summon'] + && $hero?->skills === ['Test Strike', 'Test Flame'] && $hero?->summons === ['test-call'] + && $command->setup?->members[1]->commands === null, + 'Commands, Skills and Summons reach the battle test setup, resolved by id or label'); + + [$command, $status, $display] = runBattleMembers($project, ['Hero,Commands=dance,Skills=test flame|Nothing,Summons=nowhere']); + assertBattleMembers($status === Command::INVALID && $command->setup === null + && str_contains($display, '--member Hero: there is no command dance (commands: attack, skill, magic, summon, item, guard, escape).') + && str_contains($display, '--member Hero: the project has no skill test flame (did you mean Test Flame?).') + && str_contains($display, '--member Hero: the project has no skill Nothing.') + && str_contains($display, '--member Hero: the project has no summon nowhere (summons: test-call).'), + 'every unknown command, skill and summon is named before a battle starts'); + + [$command, $status] = runBattleMembers($project, ['Mage,Summons=test-call']); + assertBattleMembers($status === Command::INVALID && $command->setup === null, + "the engine refuses a summon the member may not hold, rather than granting it"); + + [, $status, $display] = runBattleMembers($project, ['Hero,Skills=Test Strike'], ['--runs' => '2']); + assertBattleMembers($status === Command::INVALID + && str_contains($display, '--runs cannot use Commands, Skills or Summons') + && str_contains($display, 'Leave out --runs to play the fight and use them'), + '--runs refuses a loadout its attack-only simulator would never use'); + } + + [, $status, $display] = runBattleMembers($project, ['ghost'], ['--runs' => '2']); + assertBattleMembers($status === Command::INVALID && str_contains($display, 'no such actor'), + '--runs refuses the same setup problems before simulating'); + + if (method_exists(BattleTestSetup::class, 'withArena')) { + [$command, $status, $display] = runBattleMembers($project, [], ['--arena' => 'arena.lake', '--renderer' => 'gpui']); + assertBattleMembers($status === Command::INVALID && $command->setup === null + && str_contains($display, '--arena: the project declares no battle presentation, so it has no arenas.'), + 'an arena is refused in a project without a battle presentation'); + + @mkdir($project . '/assets/Data/Presentation', 0o777, true); + file_put_contents($project . '/assets/Data/Presentation/battle.php', <<<'PHP' + new BattleArenaDefinition($name, + new CanvasImage('arena', 'Graphics/Battlebacks/' . $name . '.png', new CanvasRectangle(0, 0, 1350, 720))); +return new BattlePresentationCatalog(arenas: ['arena.road' => $arena('Road'), 'arena.lake' => $arena('Lake')], actors: [], enemies: []); +PHP); + + [$command, $status] = runBattleMembers($project, [], ['--arena' => 'arena.lake', '--renderer' => 'gpui']); + assertBattleMembers($status === Command::SUCCESS && $command->setup?->arena === 'arena.lake', + '--arena sets the arena the battle test fights in'); + + [$command, $status, $display] = runBattleMembers($project, [], ['--arena' => 'arena.moon', '--renderer' => 'gpui']); + assertBattleMembers($status === Command::INVALID && $command->setup === null + && str_contains($display, '--arena: the project has no arena arena.moon (its arenas: arena.road (Road), arena.lake (Lake)).'), + 'an arena the presentation does not declare is refused, naming the ones it does'); + + [, $status, $display] = runBattleMembers($project, [], ['--arena' => 'arena.lake']); + assertBattleMembers($status === Command::INVALID && str_contains($display, 'the terminal renderer draws none'), + 'the terminal renderer refuses an arena it would not draw'); + + [, $status, $display] = runBattleMembers($project, [], ['--arena' => 'arena.lake', '--runs' => '2']); + assertBattleMembers($status === Command::INVALID && str_contains($display, 'An arena applies to playing a battle'), + '--runs refuses an arena nothing would draw'); + + if (class_exists(\Ichiloto\Engine\Scenes\Arena\ProjectBattleTest::class)) { + // The project keeps a battle test in its system data, as RPG Maker keeps its Battle Test party. + $system = $project . '/assets/Data/system.php'; + $original = (string) file_get_contents($system); + file_put_contents($system, " 'Arena Test', 'currency' => [],\n" + . " 'startingPositions' => ['player' => []], 'startingParty' => ['hero', 'mage'],\n" + . " 'battleTest' => ['troop' => 'Rats', 'arena' => 'arena.road', 'members' => [['actor' => 'mage', 'level' => 6]]]];\n"); + + [$command, $status] = runBattleMembers($project, [], ['--renderer' => 'gpui']); + assertBattleMembers($status === Command::SUCCESS + && array_map(static fn($member): array => [$member->actorId, $member->level], $command->setup?->members ?? []) === [['mage', 6]] + && $command->setup?->arena === 'arena.road', + "without --member, the project's battle test party and arena are set up"); + + [$command, $status] = runBattleMembers($project, ['hero'], ['--renderer' => 'gpui']); + assertBattleMembers($status === Command::SUCCESS && $command->setup?->members[0]->actorId === 'hero' + && $command->setup?->arena === 'arena.road', + "--member replaces only the project's battle test party, not its arena"); + + [$command, $status] = runBattleMembers($project, [], ['--arena' => 'arena.lake', '--renderer' => 'gpui']); + assertBattleMembers($status === Command::SUCCESS && $command->setup?->members[0]->actorId === 'mage' + && $command->setup?->arena === 'arena.lake', + "--arena replaces only the project's battle test arena, not its party"); + + file_put_contents($system, $original); + } + } +} finally { + removeBattleTestProject($project); +} + +fwrite(STDOUT, "PASS: battle sets up its party with --member, through the engine's battle test setup.\n"); \ No newline at end of file diff --git a/tests/battle-renderer.php b/tests/battle-renderer.php new file mode 100644 index 0000000..773f119 --- /dev/null +++ b/tests/battle-renderer.php @@ -0,0 +1,96 @@ + */ + public array $arenas = []; + + protected function runArena(string $projectName, string $troop, BattleTestSetup $setup): void + { + $this->arenas[] = ['project' => $projectName, 'troop' => $troop, 'renderer' => getenv('ICHILOTO_RENDERER'), 'setup' => $setup]; + } +} + +function assertBattleRenderer(bool $condition, string $message): void +{ + if (! $condition) { + fwrite(STDERR, "FAIL: {$message}\n"); + exit(1); + } +} + +/** @param array $options @return array{0: RecordingBattleCommand, 1: int, 2: string} */ +function runBattleRenderer(string $project, array $options, ?callable $prompt = null, bool $interactive = false): array +{ + $registry = new RendererRegistry(); + $command = new RecordingBattleCommand( + rendererRegistry: $registry, + rendererSelector: new RendererSelector($registry, $prompt), + terminalInteractivity: new TerminalInteractivity(static fn (): bool => true, static fn (): bool => true), + rendererUpdateChecker: new SourceRendererUpdateChecker(locateEngine: static fn (string $root): string => $root), + ); + $application = new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->addCommand($command); + $tester = new ApplicationTester($application); + $status = $tester->run(['command' => 'battle', '--directory' => $project, ...$options], + ['interactive' => $interactive, 'decorated' => false]); + + return [$command, $status, $tester->getDisplay()]; +} + +$project = writeBattleTestProject(); +putenv('ICHILOTO_RENDERER=outer'); + +try { + [$command, $status] = runBattleRenderer($project, ['--renderer' => 'gpui', '--troop' => 'Bat x 2']); + assertBattleRenderer($status === Command::SUCCESS, 'battle --renderer gpui succeeds'); + assertBattleRenderer(count($command->arenas) === 1 && $command->arenas[0]['project'] === 'Arena Test' + && $command->arenas[0]['troop'] === 'Bat x 2' && $command->arenas[0]['renderer'] === 'gpui', + 'the arena runs once, with the chosen renderer in the environment'); + assertBattleRenderer(getenv('ICHILOTO_RENDERER') === 'outer', 'the caller\'s renderer environment is restored'); + + [$command] = runBattleRenderer($project, ['--gpui-renderer' => true]); + assertBattleRenderer(($command->arenas[0]['renderer'] ?? null) === 'gpui', '--gpui-renderer selects GPUI'); + + [$command] = runBattleRenderer($project, [], static fn (string $label, array $options): string => 'terminal', interactive: true); + assertBattleRenderer(($command->arenas[0]['renderer'] ?? null) === 'terminal', 'an interactive battle asks which renderer to use'); + + [$command, $status, $display] = runBattleRenderer($project, ['--renderer' => 'nonsense']); + assertBattleRenderer($status === Command::INVALID && $command->arenas === [], 'an unknown renderer is refused before the arena starts'); + + [$command, $status, $display] = runBattleRenderer($project, ['--runs' => '3', '--renderer' => 'gpui']); + assertBattleRenderer($status === Command::INVALID && str_contains($display, 'not to simulating one'), + 'a renderer with --runs is refused'); +} finally { + putenv('ICHILOTO_RENDERER'); + removeBattleTestProject($project); +} + +fwrite(STDOUT, "PASS: battle selects its renderer as play does and scopes it to the arena.\n"); diff --git a/tests/fixtures/battle-project.php b/tests/fixtures/battle-project.php new file mode 100644 index 0000000..6bfdd72 --- /dev/null +++ b/tests/fixtures/battle-project.php @@ -0,0 +1,75 @@ + 'Arena Test'])); + file_put_contents($root . '/vendor/autoload.php', " 'Arena Test', 'currency' => [],\n" + . " 'startingPositions' => ['player' => []], 'startingParty' => ['hero', 'mage']];\n"); + foreach (['hero' => 'Hero', 'mage' => 'Mage'] as $id => $name) { + file_put_contents($root . "/assets/Data/Actors/{$name}.php", ' [ + 'id' => $id, 'name' => $name, '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"); + } + foreach ([ + 'Items/0001-potion.php' => ['kind' => 'item', 'id' => 'item.potion', 'name' => 'Potion', 'description' => '', 'icon' => '', 'price' => 10], + 'Weapons/0001-iron-sword.php' => ['kind' => 'weapon', 'id' => 'equipment.iron-sword', 'name' => 'Iron Sword', 'description' => '', 'icon' => '/', 'price' => 50], + ] as $file => $data) { + is_dir(dirname($root . '/assets/Data/' . $file)) || mkdir(dirname($root . '/assets/Data/' . $file), 0o777, true); + file_put_contents($root . '/assets/Data/' . $file, " \\Ichiloto\\Engine\\Entities\\Inventory\\InventoryItem::class, 'data' => " + . var_export($data, true) . "];\n"); + } + file_put_contents($root . '/assets/Data/items.php', " [$kind, $name, $cost]) { + file_put_contents(sprintf('%s/assets/Data/Skills/%04d-%s.php', $root, $number + 1, strtolower(str_replace(' ', '-', $name))), + " \\Ichiloto\\Engine\\Entities\\Skills\\Skill::class, 'data' => " . var_export([ + 'kind' => $kind, 'name' => $name, 'description' => '', 'icon' => '', 'cost' => $cost, 'cooldown' => 0, 'occasion' => 'Always', + 'scope' => ['side' => 'Enemy', 'number' => 'One', 'status' => 'Alive'], + 'invocation' => ['message' => '$1 casts $2!', 'speed' => 0, 'accuracy' => 0, 'repeat' => 1, 'apGain' => 10], 'effects' => [], + ], true) . "];\n"); + } + $directory = $root . '/assets/Cutscenes/Summons/test-call'; + mkdir($directory, 0o777, true); + file_put_contents($directory . '/test-call.data.php', ' 'test-call', 'name' => 'Test Call', 'linkedActionId' => 'Test Call', + 'availability' => ['conditions' => [['type' => 'event', 'name' => 'unearned_story_unlock']]], + 'wielders' => ['mode' => 'characters', 'characters' => ['Hero'], 'tenancy' => 'exclusive'], + ], true) . ";\n"); + file_put_contents($directory . '/test-call.timeline.php', " 12, 'lengthFrames' => 2, 'tracks' => [], 'cues' => []];\n"); +} + +function removeBattleTestProject(string $root): void +{ + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($root); +} diff --git a/tests/fixtures/released-console-0.5.0-project/.data/saves/.gitkeep b/tests/fixtures/released-console-0.5.0-project/.data/saves/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/released-console-0.5.0-project/.gitignore b/tests/fixtures/released-console-0.5.0-project/.gitignore new file mode 100644 index 0000000..8c4ea25 --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/.gitignore @@ -0,0 +1,10 @@ +/vendor/ +/.idea/ +/.vscode/ +/.env +*.log +logs/* +!logs/.gitkeep +.data/saves/* +!.data/saves/.gitkeep +*.iedata \ No newline at end of file diff --git a/tests/fixtures/released-console-0.5.0-project/assets/Data/Actors/AriaVale.php b/tests/fixtures/released-console-0.5.0-project/assets/Data/Actors/AriaVale.php new file mode 100644 index 0000000..7672fc9 --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/assets/Data/Actors/AriaVale.php @@ -0,0 +1,45 @@ + Character::class, + 'data' => [ + 'name' => 'Aria Vale', + 'description' => '', + 'level' => 1, + 'currentExp' => 0, + 'stats' => [ + 'currentHp' => 100, + 'currentMp' => 20, + 'currentAp' => 10, + 'totalHp' => 100, + 'totalMp' => 20, + 'totalAp' => 10, + 'attack' => 10, + 'defence' => 10, + 'magicAttack' => 10, + 'magicDefence' => 10, + 'grace' => 10, + 'speed' => 10, + 'evasion' => 5, + 'accuracy' => 5, + 'critical' => 5, + ], + 'images' => [ + 'dialog' => [], + 'field' => [], + 'battle' => [], + ], + 'abilities' => [ + 'learned' => [], + 'learnables' => [], + 'sortOrder' => 'A-Z', + ], + 'magic' => [ + 'learned' => [], + 'learnables' => [], + 'sortOrder' => 'A-Z', + ], + ], +]; \ No newline at end of file diff --git a/tests/fixtures/released-console-0.5.0-project/assets/Data/Entities/player.php b/tests/fixtures/released-console-0.5.0-project/assets/Data/Entities/player.php new file mode 100644 index 0000000..1433abb --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/assets/Data/Entities/player.php @@ -0,0 +1,10 @@ + [ + 'north' => ['^'], + 'east' => ['>'], + 'south' => ['v'], + 'west' => ['<'], + ], +]; \ No newline at end of file diff --git a/tests/fixtures/released-console-0.5.0-project/assets/Data/abilities.php b/tests/fixtures/released-console-0.5.0-project/assets/Data/abilities.php new file mode 100644 index 0000000..0b67a5f --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/assets/Data/abilities.php @@ -0,0 +1,3 @@ + 0, + 'migrations' => [], + 'aliases' => [], + 'tombstones' => [], +]; diff --git a/tests/fixtures/released-console-0.5.0-project/assets/Data/skills.php b/tests/fixtures/released-console-0.5.0-project/assets/Data/skills.php new file mode 100644 index 0000000..0b67a5f --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/assets/Data/skills.php @@ -0,0 +1,3 @@ + 'Released 0.5 Project', + 'currency' => [ + 'amount' => 0, + ], + 'startingParty' => [ + 'AriaVale', + ], + 'startingInventory' => [], + 'startingPositions' => [ + 'player' => [ + 'destinationMap' => 'campfire-clearing', + 'spawnPoint' => [ + 'x' => 4, + 'y' => 4, + ], + 'spawnSprite' => [ + 'v', + ], + ], + ], + 'battle' => [ + 'engine' => 'traditional', + 'activeTime' => [ + 'mode' => 'wait', + 'baseFillRate' => 35, + 'speedFactorPercent' => 100, + 'openingVariance' => 24, + 'openingSpeedFactorPercent' => 250, + 'surpriseAttackChancePercent' => 8, + 'backAttackChancePercent' => 6, + ], + ], +]; diff --git a/tests/fixtures/released-console-0.5.0-project/assets/Data/troops.php b/tests/fixtures/released-console-0.5.0-project/assets/Data/troops.php new file mode 100644 index 0000000..0b67a5f --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/assets/Data/troops.php @@ -0,0 +1,3 @@ + 'Campfire Clearing', + 'region' => 'Prologue', + 'description' => 'A quiet clearing where new legends are first etched into the dark.', + 'triggers' => + array ( + ), + 'events' => + array ( + ), +); diff --git a/tests/fixtures/released-console-0.5.0-project/assets/Maps/campfire-clearing/campfire-clearing.event.php b/tests/fixtures/released-console-0.5.0-project/assets/Maps/campfire-clearing/campfire-clearing.event.php new file mode 100644 index 0000000..76603ac --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/assets/Maps/campfire-clearing/campfire-clearing.event.php @@ -0,0 +1,22 @@ + CollisionType::ENCOUNTER, + '~' => CollisionType::SOLID, + '|' => CollisionType::SOLID, + '-' => CollisionType::SOLID, + '(' => CollisionType::SOLID, + ')' => CollisionType::SOLID, + 'x' => CollisionType::SOLID, + '.' => CollisionType::SOLID, + '`' => CollisionType::SOLID, + '#' => CollisionType::SOLID, + ':' => CollisionType::SOLID, + '?' => CollisionType::SAVE_POINT, + 'o' => CollisionType::COLLECTABLE, + ' ' => CollisionType::NONE, + '@' => CollisionType::NPC, +]; \ No newline at end of file diff --git a/tests/fixtures/released-console-0.5.0-project/composer.json b/tests/fixtures/released-console-0.5.0-project/composer.json new file mode 100644 index 0000000..527e796 --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/composer.json @@ -0,0 +1,12 @@ +{ + "name": "ichiloto/released-0-5-project", + "description": "A terminal-native RPG created with the Ichiloto Engine.", + "type": "project", + "require": { + "php": "^8.4", + "ichiloto/engine": "^0.5" + }, + "scripts": { + "play": "php released-0-5-project.php" + } +} diff --git a/tests/fixtures/released-console-0.5.0-project/config.php b/tests/fixtures/released-console-0.5.0-project/config.php new file mode 100644 index 0000000..53f35c0 --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/config.php @@ -0,0 +1,103 @@ + [ + 'game' => [ + 'new_game' => 'New Game', + 'continue' => 'Load Game', + 'options' => 'Options', + 'save' => 'Save', + 'load' => 'Load', + 'shutdown' => 'Exit', + 'to_title' => 'To Title', + ], + 'shop' => [ + 'buy' => 'Buy', + 'sell' => 'Sell', + 'cancel' => 'Cancel', + 'possession' => 'Possession', + 'exp_total' => 'Current Exp', + ], + 'battle' => [], + 'command' => [ + 'attack' => 'Attack', + 'skill' => 'Skill', + 'guard' => 'Guard', + 'item' => 'Item', + 'equip' => 'Equip', + 'status' => 'Status', + 'escape' => 'Escape', + 'new_game' => 'New Game', + 'continue' => 'Continue', + 'options' => 'Options', + 'game_end' => 'Quit', + ], + 'currency' => [ + 'name' => 'Gold', + 'symbol' => 'G', + ], + ], + 'messages' => [ + 'exp_total' => 'Current %1', + 'exp_next' => 'To Next %1', + 'party_name' => '%1\'s Party', + 'obtained_exp' => '%1 %2 obtained!', + 'obtained_gold' => '%1 %2 found!', + 'obtained_item' => '%1 found!', + 'file' => 'File', + 'prompt' => [ + 'save' => 'Save to which file?', + 'load' => 'Load from which file?', + ], + 'confirm' => [ + 'quit' => 'Are you sure you want to quit?', + ], + 'inventory' => [ + 'empty' => 'No items.', + 'quantity' => 'x%1', + 'possession' => 'Possession', + 'gold' => 'Gold', + 'equip' => 'Equip', + 'use' => 'Use', + 'discard' => 'Discard', + 'cancel' => 'Cancel', + ], + ], + 'ui' => [ + 'cursor' => [ + 'memory' => true, + ], + 'dialogue' => [ + 'speed' => 20, + 'window' => [ + 'position' => \Ichiloto\Engine\UI\Windows\Enumerations\WindowPosition::TOP, + ], + 'message' => [ + 'speed' => 20, + ], + ], + 'battle' => [ + 'message_pace' => 'slow', + 'animation_pace' => 'slow', + 'selection_color' => \Ichiloto\Engine\IO\Enumerations\Color::LIGHT_CYAN, + ], + 'menu' => [ + 'selection_color' => \Ichiloto\Engine\IO\Enumerations\Color::LIGHT_CYAN, + ], + 'hud' => [ + 'location' => true, + ], + 'notifications' => [ + 'duration' => 3000, + ], + ], + 'audio' => [ + 'master_volume' => 70, + 'music' => true, + 'sfx' => true, + ], + 'inn' => [ + 'base_cost' => 30, + 'sleep_time' => 3, + ], +]; \ No newline at end of file diff --git a/tests/fixtures/released-console-0.5.0-project/ichiloto.json b/tests/fixtures/released-console-0.5.0-project/ichiloto.json new file mode 100644 index 0000000..9d3a510 --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/ichiloto.json @@ -0,0 +1,22 @@ +{ + "id": "ichiloto/released-0-5-project", + "name": "Released 0.5 Project", + "description": "A terminal-born RPG forged with the Ichiloto Engine.", + "version": "0.1.0", + "author": "", + "main": "released-0-5-project.php", + "paths": { + "assets": "assets/" + }, + "debug": { + "enabled": false, + "level": 2, + "show": false, + "skip_splash": true + }, + "splash_screen": { + "enabled": false, + "filename": "", + "duration": 2 + } +} diff --git a/tests/fixtures/released-console-0.5.0-project/input.php b/tests/fixtures/released-console-0.5.0-project/input.php new file mode 100644 index 0000000..5b4ef0d --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/input.php @@ -0,0 +1,66 @@ + [ + 'description' => 'Perform an action.', + 'keys' => [KeyCode::SPACE, KeyCode::ENTER], + ], + 'cancel' => [ + 'description' => 'Cancel the current action.', + 'keys' => [KeyCode::C, KeyCode::c, KeyCode::ESCAPE], + ], + 'back' => [ + 'description' => 'Go back.', + 'keys' => [KeyCode::ESCAPE], + ], + 'confirm' => [ + 'description' => 'Confirm the current action.', + 'keys' => [KeyCode::ENTER], + ], + 'character_next' => [ + 'description' => 'Cycle to the next character.', + 'keys' => [KeyCode::TAB], + ], + 'character_previous' => [ + 'description' => 'Cycle to the previous character.', + 'keys' => [KeyCode::SHIFT_TAB], + ], + 'quit' => [ + 'description' => 'Quit the game.', + 'keys' => [KeyCode::Q, KeyCode::q], + ], + 'up' => [ + 'description' => 'Move up.', + 'keys' => [KeyCode::UP, KeyCode::W, KeyCode::w], + ], + 'down' => [ + 'description' => 'Move down.', + 'keys' => [KeyCode::DOWN, KeyCode::S, KeyCode::s], + ], + 'left' => [ + 'description' => 'Move left.', + 'keys' => [KeyCode::LEFT, KeyCode::A, KeyCode::a], + ], + 'right' => [ + 'description' => 'Move right.', + 'keys' => [KeyCode::RIGHT, KeyCode::D, KeyCode::d], + ], + 'menu' => [ + 'description' => 'Open the in-game menu.', + 'keys' => [KeyCode::ESCAPE], + ], + 'map' => [ + 'description' => 'Open the map.', + 'keys' => [KeyCode::M, KeyCode::m], + ], + 'skit' => [ + 'description' => 'Play an available skit.', + 'keys' => [KeyCode::T, KeyCode::t], + ], + 'pause' => [ + 'description' => 'Pause the game.', + 'keys' => [KeyCode::ESCAPE], + ], +]; \ No newline at end of file diff --git a/tests/fixtures/released-console-0.5.0-project/logs/.gitkeep b/tests/fixtures/released-console-0.5.0-project/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/released-console-0.5.0-project/released-0-5-project.php b/tests/fixtures/released-console-0.5.0-project/released-0-5-project.php new file mode 100644 index 0000000..51340c0 --- /dev/null +++ b/tests/fixtures/released-console-0.5.0-project/released-0-5-project.php @@ -0,0 +1,17 @@ +run(); \ No newline at end of file diff --git a/tests/fixtures/renderer-builder.php b/tests/fixtures/renderer-builder.php new file mode 100644 index 0000000..0b73b1c --- /dev/null +++ b/tests/fixtures/renderer-builder.php @@ -0,0 +1,35 @@ + 'gpui', 'platform' => $platform, 'profile' => 'release', + 'fingerprint' => $fingerprint, 'packageDirectory' => $package], JSON_THROW_ON_ERROR); + exit; +} +file_put_contents($source . '/builds', "build\n", FILE_APPEND | LOCK_EX); +if ($mode === 'fail') { fwrite(STDERR, 'Deliberate build failure'); exit(1); } +if ($mode === 'hung-build') { sleep(10); } +if ($mode === 'changed') { file_put_contents($source . '/input', file_get_contents($source . '/input') . '-edited'); } +if ($mode === 'slow') { usleep(150000); } +$renderer = $mode === 'wrong-renderer' ? 'other' : 'gpui'; +$target = $mode === 'wrong-platform' ? 'other-x64' : $platform; +$relative = $renderer . '/' . $target . '/renderer'; +mkdir($package . '/' . dirname($relative), 0755, true); +file_put_contents($package . '/' . $relative, 'fake-optimized-renderer:' . $fingerprint); +file_put_contents($package . '/' . dirname($relative) . '/resource', 'bundle-resource'); +$files = [$relative => hash_file('sha256', $package . '/' . $relative), + dirname($relative) . '/resource' => hash_file('sha256', $package . '/' . dirname($relative) . '/resource')]; +if ($mode === 'bad-hash') { $files[$relative] = str_repeat('0', 64); } +file_put_contents($package . '/renderer-package.json', json_encode(['version' => 1, 'renderer' => $renderer, + 'platform' => $target, 'packageVersion' => '0.1.0', 'executable' => $relative, 'files' => $files], JSON_THROW_ON_ERROR)); +echo "Fake release package ready.\n"; diff --git a/tests/fixtures/renderer-preparation-worker.php b/tests/fixtures/renderer-preparation-worker.php new file mode 100644 index 0000000..076ccb7 --- /dev/null +++ b/tests/fixtures/renderer-preparation-worker.php @@ -0,0 +1,8 @@ +update($argv[1], 'gpui', + new \Symfony\Component\Console\Output\NullOutput()); diff --git a/tests/generate-actor-identity.php b/tests/generate-actor-identity.php new file mode 100644 index 0000000..9f9ae66 --- /dev/null +++ b/tests/generate-actor-identity.php @@ -0,0 +1,51 @@ +execute(['name' => 'Aria Vale'], ['interactive' => false]); + if ($result !== Command::SUCCESS || ! is_file($actorPath)) { + throw new RuntimeException('generate:actor did not create the actor. ' . $tester->getDisplay()); + } + + $actor = require $actorPath; + if (($actor['data']['name'] ?? null) !== 'Aria Vale' + || ($actor['data']['id'] ?? null) !== 'Aria Vale') { + throw new RuntimeException('generate:actor did not preserve the original display name as the stable id.'); + } + + $before = file_get_contents($actorPath); + if ($tester->execute(['name' => 'Aria Vale'], ['interactive' => false]) !== Command::FAILURE + || file_get_contents($actorPath) !== $before) { + throw new RuntimeException('generate:actor overwrote an existing actor without --force.'); + } +} finally { + chdir($previousDirectory); + if (is_file($actorPath)) { + unlink($actorPath); + } + if (is_dir($root . '/assets/Data/Actors')) { + rmdir($root . '/assets/Data/Actors'); + rmdir($root . '/assets/Data'); + rmdir($root . '/assets'); + } + if (is_dir($root)) { + rmdir($root); + } +} + +fwrite(STDOUT, "PASS: generated actors declare stable ids and preserve existing files.\n"); diff --git a/tests/generate-map.php b/tests/generate-map.php index 3d16b54..516d2fa 100644 --- a/tests/generate-map.php +++ b/tests/generate-map.php @@ -79,6 +79,11 @@ function failMapTest(string $message): never failMapTest('generate:map wrote invalid map metadata.'); } + // A project without tilesets has no kinds, so its map has none. + if (array_key_exists('tileset', $data) || ! str_contains($tester->getDisplay(), 'Kind: none')) { + failMapTest('generate:map gave a kind to a map in a project without tilesets.'); + } + if (! is_string($tiles) || ! is_string($events)) { failMapTest('generate:map layers must return strings.'); } @@ -114,7 +119,34 @@ function failMapTest(string $message): never failMapTest('generate:map could not replace the complete map with --force.'); } - fwrite(STDOUT, "PASS: generate:map writes the complete Engine 0.5 map layout.\n"); + // In a project with tilesets, every new map is created with its kind. + mkdir($temporaryRoot . '/assets/Data/Tilesets', 0755, true); + foreach (['interior' => 'Interior', 'exterior' => 'Exterior'] as $id => $name) { + file_put_contents($temporaryRoot . "/assets/Data/Tilesets/{$id}.php", + " '{$name}', 'sheets' => ['B' => 'Graphics/Tilesets/{$name}_B.png']];\n"); + } + $kindDirectory = $mapsRoot . '/lantern-hall'; + $kindArguments = ['name' => 'Lantern Hall', '--region' => '', '--description' => '']; + + if ($tester->execute($kindArguments, ['interactive' => false]) !== Command::FAILURE + || ! str_contains($tester->getDisplay(), "Name the map's kind with --kind: exterior, interior.") + || is_dir($kindDirectory)) { + failMapTest('generate:map created a map without a kind in a project that has tilesets.'); + } + + if ($tester->execute([...$kindArguments, '--kind' => 'cavern'], ['interactive' => false]) !== Command::FAILURE + || ! str_contains($tester->getDisplay(), "cavern is not one of the project's kinds: exterior, interior.") + || is_dir($kindDirectory)) { + failMapTest('generate:map accepted a kind the project does not have.'); + } + + if ($tester->execute([...$kindArguments, '--kind' => 'interior'], ['interactive' => false]) !== Command::SUCCESS + || ((require $kindDirectory . '/lantern-hall.data.php')['tileset'] ?? null) !== 'interior' + || ! str_contains($tester->getDisplay(), 'Kind: Interior')) { + failMapTest('generate:map did not create the map with the kind it was given: ' . $tester->getDisplay()); + } + + fwrite(STDOUT, "PASS: generate:map writes the complete Engine 0.5 map layout, with its kind.\n"); } catch (Throwable $throwable) { fwrite(STDERR, "FAIL: {$throwable->getMessage()}\n"); exit(1); diff --git a/tests/gui-editor-launch.php b/tests/gui-editor-launch.php new file mode 100644 index 0000000..5bbd7e0 --- /dev/null +++ b/tests/gui-editor-launch.php @@ -0,0 +1,75 @@ +locate() === null, 'nothing is found before anything is built'); + assertGuiLaunch(str_contains($locator->describeMissing(), 'cargo build --release'), 'the missing message says how to build it'); + + file_put_contents($debug, "#!/bin/sh\n"); + chmod($debug, 0755); + assertGuiLaunch($locator->locate() === $debug, 'a debug build is found'); + + file_put_contents($release, "#!/bin/sh\n"); + chmod($release, 0755); + assertGuiLaunch($locator->locate() === $release, 'a release build is preferred to a debug one'); + + $configured = new GuiEditorLocator($console, $workspace . '/elsewhere'); + assertGuiLaunch($configured->locate() === null, 'a configured path that is not an executable is not replaced by a build'); + assertGuiLaunch(str_contains($configured->describeMissing(), GuiEditorLocator::ENVIRONMENT), 'the message names the variable'); + assertGuiLaunch((new GuiEditorLocator($console, $debug))->locate() === $debug, 'a configured executable wins'); + + // On macOS the bundle, whose executable links to the release build, names the editor Ichiloto. + $bundle = $workspace . '/gui-editor/' . GuiEditorLocator::MACOS_BUNDLE_EXECUTABLE; + mkdir(dirname($bundle), 0777, true); + symlink('../../../../../target/release/' . GuiEditorLocator::BINARY, $bundle); + assertGuiLaunch((new GuiEditorLocator($console, platform: 'Darwin'))->locate() === $bundle, 'on macOS the application bundle is preferred'); + assertGuiLaunch($locator->locate() === $release, 'elsewhere the bundle is not a candidate'); + unlink($release); + assertGuiLaunch((new GuiEditorLocator($console, platform: 'Darwin'))->locate() === $debug, 'a bundle without a release build to run is passed over'); +} finally { + foreach ([$release, $debug] as $file) { + @unlink($file); + } + exec('rm -rf ' . escapeshellarg($workspace)); +} + +// Both editors playtest through the console that opened them, unless the author named another. +putenv(EditorProjectBootstrap::CONSOLE_BINARY_ENVIRONMENT); +EditorProjectBootstrap::nameConsoleBinary(); +assertGuiLaunch(getenv(EditorProjectBootstrap::CONSOLE_BINARY_ENVIRONMENT) === EditorProjectBootstrap::getConsoleBinary(), 'the editor is told which console opened it'); +assertGuiLaunch(is_file(EditorProjectBootstrap::getConsoleBinary()), 'the named console exists'); +putenv(EditorProjectBootstrap::CONSOLE_BINARY_ENVIRONMENT . '=/elsewhere/ichiloto'); +EditorProjectBootstrap::nameConsoleBinary(); +assertGuiLaunch(getenv(EditorProjectBootstrap::CONSOLE_BINARY_ENVIRONMENT) === '/elsewhere/ichiloto', 'a console the author named is kept'); +putenv(EditorProjectBootstrap::CONSOLE_BINARY_ENVIRONMENT); + +echo "gui-editor-launch: ok\n"; diff --git a/tests/legacy-project-upgrade.php b/tests/legacy-project-upgrade.php index 649351d..6a87984 100644 --- a/tests/legacy-project-upgrade.php +++ b/tests/legacy-project-upgrade.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Ichiloto\Console\Support\SaveCompatibilityMetadata; +use Ichiloto\Console\Upgrade\ProjectUpgradeContext; +use Ichiloto\Console\Upgrade\Steps\SaveMetadataStep; use Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest; require dirname(__DIR__) . '/vendor/autoload.php'; @@ -98,7 +100,28 @@ function failUpgradeTest(string $message): never 'name' => 'moon-studio/legacy-moon', ]); - $first = runUpgradeCommand($consoleBin, $consoleRoot, ['--directory', $legacyRoot]); + // Format 1 on its own: the step writes exactly what the metadata upgrade always wrote. + $stepRoot = $temporaryRoot . '/step-one'; + writeJsonFixture($stepRoot . '/ichiloto.json', ['name' => 'Legacy Moon', 'main' => 'legacy-moon.php']); + writeJsonFixture($stepRoot . '/composer.json', ['name' => 'moon-studio/legacy-moon']); + $step = new SaveMetadataStep(); + $stepContext = new ProjectUpgradeContext($stepRoot); + $stepPlan = $step->createPlan($stepContext); + $expectedConfig = json_encode( + ['id' => 'moon-studio/legacy-moon', 'name' => 'Legacy Moon', 'main' => 'legacy-moon.php'], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES, + ) . PHP_EOL; + $stepReport = $step->applyPlan($stepContext, $stepPlan); + + if ($step->getTargetVersion() !== 1 + || file_get_contents($stepRoot . '/ichiloto.json') !== $expectedConfig + || file_get_contents($stepRoot . '/assets/Data/save-compatibility.php') !== SaveCompatibilityMetadata::renderBaseline() + || $stepReport->writtenPaths !== ['ichiloto.json', 'assets/Data/save-compatibility.php'] + || $step->createPlan($stepContext)->writes !== []) { + failUpgradeTest('Format 1 no longer writes exactly the legacy save metadata, or is not idempotent.'); + } + + $first = runUpgradeCommand($consoleBin, $consoleRoot, ['--directory', $legacyRoot, '--yes']); if ($first['exitCode'] !== 0) { failUpgradeTest('The legacy project did not upgrade: ' . $first['output']); @@ -109,12 +132,13 @@ function failUpgradeTest(string $message): never $config = json_decode((string) file_get_contents($configPath), true); $manifest = require $manifestPath; - if (($config['id'] ?? null) !== 'moon-studio/legacy-moon' || ($config['main'] ?? null) !== 'legacy-moon.php') { - failUpgradeTest('The upgrade did not preserve config while adopting the canonical Composer identity.'); + if (($config['id'] ?? null) !== 'moon-studio/legacy-moon' || ($config['main'] ?? null) !== 'legacy-moon.php' + || ($config['format'] ?? null) !== SaveMetadataStep::VERSION) { + failUpgradeTest('The upgrade did not preserve config while adopting the canonical Composer identity and recording the format.'); } - if ($manifest !== SaveCompatibilityMetadata::baseline()) { - failUpgradeTest('The upgrade did not create the canonical version-0 compatibility manifest.'); + if ($manifest !== SaveCompatibilityMetadata::baseline() || ! is_file($legacyRoot . '/ichiloto-upgrade-report.md')) { + failUpgradeTest('The upgrade did not create the canonical version-0 compatibility manifest and its report.'); } load_engine_autoloader($legacyRoot); @@ -126,7 +150,7 @@ function failUpgradeTest(string $message): never $configSource = (string) file_get_contents($configPath); $manifestSource = (string) file_get_contents($manifestPath); - $second = runUpgradeCommand($consoleBin, $consoleRoot, ['--directory', $legacyRoot]); + $second = runUpgradeCommand($consoleBin, $consoleRoot, ['--directory', $legacyRoot, '--yes']); if ($second['exitCode'] !== 0 || ! str_contains($second['output'], 'No upgrade is needed') @@ -154,11 +178,17 @@ function failUpgradeTest(string $message): never mkdir($preservedRoot . '/assets/Data', 0777, true); $customManifest = " 7, 'custom' => true];\n"; file_put_contents($preservedRoot . '/assets/Data/save-compatibility.php', $customManifest); - $preserved = runUpgradeCommand($consoleBin, $consoleRoot, ['--directory', $preservedRoot]); + if (new SaveMetadataStep()->createPlan(new ProjectUpgradeContext($preservedRoot))->writes !== []) { + failUpgradeTest('Format 1 planned to rewrite existing save identity or compatibility metadata.'); + } + + $preserved = runUpgradeCommand($consoleBin, $consoleRoot, ['--directory', $preservedRoot, '--yes']); + $preservedConfig = json_decode((string) file_get_contents($preservedRoot . '/ichiloto.json'), true); if ($preserved['exitCode'] !== 0 + || ($preservedConfig['id'] ?? null) !== 'studio/preserved-game' || file_get_contents($preservedRoot . '/assets/Data/save-compatibility.php') !== $customManifest) { - failUpgradeTest('Existing save identity or compatibility metadata was overwritten.'); + failUpgradeTest('Existing save identity or compatibility metadata was overwritten: ' . $preserved['output']); } $invalidRoot = $temporaryRoot . '/invalid-id'; @@ -166,6 +196,7 @@ function failUpgradeTest(string $message): never $invalid = runUpgradeCommand($consoleBin, $consoleRoot, [ '--directory', $invalidRoot, '--id', 'Not A Stable Identity', + '--yes', ]); if ($invalid['exitCode'] === 0 @@ -184,4 +215,4 @@ function failUpgradeTest(string $message): never exit(1); } -fwrite(STDOUT, "PASS: legacy projects gain stable, idempotent save metadata without overwriting existing contracts.\n"); +fwrite(STDOUT, "PASS: legacy projects gain stable, idempotent save metadata without overwriting existing contracts through the format chain.\n"); diff --git a/tests/new-project-save-compatibility.php b/tests/new-project-save-compatibility.php index e38bc6f..a908b7a 100644 --- a/tests/new-project-save-compatibility.php +++ b/tests/new-project-save-compatibility.php @@ -3,6 +3,9 @@ declare(strict_types=1); use Ichiloto\Console\Support\NewProjectScaffolder; +use Ichiloto\Engine\Core\ProjectFormat; +use Ichiloto\Engine\Field\MapGridSource; +use Ichiloto\Engine\Field\MapLayer; require dirname(__DIR__) . '/vendor/autoload.php'; @@ -46,6 +49,23 @@ function failScaffolderTest(string $message): never throw new TestFailure($message); } +/** @return array{code: int, output: string} */ +function validateScaffoldedProject(string $projectRoot, array $options = []): array +{ + $command = [PHP_BINARY, dirname(__DIR__) . '/bin/ichiloto', 'validate', '--no-ansi', '--no-interaction', '--directory', $projectRoot, ...$options]; + $process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, dirname(__DIR__)); + if (! is_resource($process)) { + failScaffolderTest('Could not start project validation.'); + } + + fclose($pipes[0]); + $output = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return ['code' => proc_close($process), 'output' => $output]; +} + try { $scaffolder->ensureTargetIsAvailable($projectRoot); $result = $scaffolder->scaffold([ @@ -62,15 +82,36 @@ function failScaffolderTest(string $message): never $composerPath = $projectRoot . '/composer.json'; $inputPath = $projectRoot . '/input.php'; $manifestPath = $projectRoot . '/assets/Data/save-compatibility.php'; + $heroPath = $projectRoot . '/assets/Data/Actors/Hero.php'; $project = json_decode((string) file_get_contents($projectConfigPath), true); $composer = json_decode((string) file_get_contents($composerPath), true); $inputSource = (string) file_get_contents($inputPath); $manifest = require $manifestPath; + $hero = require $heroPath; + $system = require $projectRoot . '/assets/Data/system.php'; + $ignore = (string) file_get_contents($projectRoot . '/.gitignore'); if (($project['id'] ?? null) !== 'ichiloto/save-ready-project') { failScaffolderTest('The generated project has no deterministic stable save identity.'); } + if (($project[ProjectFormat::KEY] ?? null) !== ProjectFormat::CURRENT) { + failScaffolderTest('The generated project does not record the current project format.'); + } + + // One terminal column is one map cell: the event layer matches the terrain + // cell for cell, holds no markers, and the new game starts on open ground. + $mapDirectory = $projectRoot . '/assets/Maps/campfire-clearing'; + $terrain = MapLayer::parseGrid(MapGridSource::readFile($mapDirectory . '/campfire-clearing.map.php')); + $events = MapLayer::parseGrid(MapGridSource::readFile($mapDirectory . '/campfire-clearing.event.php')); + $spawn = $system['startingPositions']['player']['spawnPoint'] ?? []; + + if (array_map(count(...), $terrain) !== array_map(count(...), $events) + || array_filter(array_merge(...$events), static fn(string $cell): bool => $cell !== ' ') !== [] + || ($terrain[$spawn['y'] ?? -1][$spawn['x'] ?? -1] ?? null) !== ' ') { + failScaffolderTest('The starter map has no matching blank event layer or an open start cell.'); + } + if (($composer['name'] ?? null) !== $project['id']) { failScaffolderTest('The generated Composer package and project save identities differ.'); } @@ -92,6 +133,116 @@ function failScaffolderTest(string $message): never failScaffolderTest('The generated-file report omits the save compatibility manifest.'); } + if (($hero['data']['id'] ?? null) !== ($hero['data']['name'] ?? null)) { + failScaffolderTest('The starter hero does not declare the original display name as a stable id.'); + } + + if (($system['startingParty'][0] ?? null) !== ($hero['data']['id'] ?? null)) { + failScaffolderTest('The starting party does not reference the hero by its declared stable id.'); + } + + if (! str_contains($ignore, '/.data/player-settings.json')) { + failScaffolderTest('The generated project does not ignore machine-local player settings.'); + } + + $freshValidation = validateScaffoldedProject($projectRoot); + if ($freshValidation['code'] !== 0) { + failScaffolderTest('The freshly scaffolded project does not validate: ' . $freshValidation['output']); + } + + // Enemies are records in Enemies/, which enemies.php loads. + $enemiesPath = $projectRoot . '/assets/Data/enemies.php'; + $workingDirectory = (string) getcwd(); + chdir($projectRoot); + try { + $startingEnemies = require $enemiesPath; + mkdir($projectRoot . '/assets/Data/Enemies'); + mkdir($projectRoot . '/assets/Graphics/Enemies', 0777, true); + file_put_contents($projectRoot . '/assets/Graphics/Enemies/slime.txt', "(~)\n"); + file_put_contents($projectRoot . '/assets/Data/Enemies/slime.php', " Enemy::class, 'data' => " + . var_export(['name' => 'Slime', 'level' => 1, 'imagePath' => 'slime', '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"); + $enemies = require $enemiesPath; + } finally { + chdir($workingDirectory); + } + if ($startingEnemies !== [] || array_map(static fn(object $enemy): string => $enemy->name, $enemies) !== ['Slime']) { + failScaffolderTest('The new project does not load the enemy records in Enemies/.'); + } + + // Skills are records in Skills/, which skills.php loads. + $skillsPath = $projectRoot . '/assets/Data/skills.php'; + chdir($projectRoot); + try { + $startingSkills = require $skillsPath; + mkdir($projectRoot . '/assets/Data/Skills'); + file_put_contents($projectRoot . '/assets/Data/Skills/0001-spark.php', " Skill::class, 'data' => " + . var_export(['kind' => 'magic', 'name' => 'Spark', 'description' => '', 'icon' => '', 'cost' => 2, 'cooldown' => 0, 'occasion' => 'Always', + 'scope' => ['side' => 'Enemy', 'number' => 'One', 'status' => 'Alive'], + 'invocation' => ['message' => '$1 casts $2!', 'speed' => 0, 'accuracy' => 0, 'repeat' => 1, 'apGain' => 10], 'effects' => []], true) . "];\n"); + $skills = require $skillsPath; + } finally { + chdir($workingDirectory); + } + if ($startingSkills !== [] || array_map(static fn(object $skill): string => $skill->name, $skills) !== ['Spark'] + || is_file($projectRoot . '/assets/Data/abilities.php') || is_file($projectRoot . '/assets/Data/magic.php')) { + failScaffolderTest('The new project does not load the skill records in Skills/.'); + } + + // Items, weapons and armors are records in Items/, Weapons/ and Armors/, which items.php loads. + $itemsPath = $projectRoot . '/assets/Data/items.php'; + chdir($projectRoot); + try { + $startingItems = require $itemsPath; + mkdir($projectRoot . '/assets/Data/Weapons'); + file_put_contents($projectRoot . '/assets/Data/Weapons/0001-iron-sword.php', " InventoryItem::class, 'data' => " + . var_export(['kind' => 'weapon', 'id' => 'equipment.iron-sword', 'name' => 'Iron Sword', 'description' => '', 'icon' => '/', 'price' => 50, 'equipmentType' => 'Sword'], true) . "];\n"); + $items = require $itemsPath; + } finally { + chdir($workingDirectory); + } + if ($startingItems !== [] || array_map(static fn(object $item): string => $item->id, $items) !== ['equipment.iron-sword']) { + failScaffolderTest('The new project does not load the inventory records in Items/, Weapons/ and Armors/.'); + } + + $legacySource = str_replace(" 'id' => 'Hero',\n", '', (string) file_get_contents($heroPath), $removedIds); + if ($removedIds !== 1) { + failScaffolderTest('The test could not create a legacy actor without an id.'); + } + $legacySource = str_replace(" 'name' => 'Hero',", " // Keep this authored comment.\n 'name' => 'Hero',", $legacySource); + file_put_contents($heroPath, $legacySource); + + $legacyValidation = validateScaffoldedProject($projectRoot); + if ($legacyValidation['code'] === 0 + || ! str_contains($legacyValidation['output'], 'Actor has no explicit stable id') + || ! str_contains($legacyValidation['output'], '--migrate-actor-ids') + || file_get_contents($heroPath) !== $legacySource) { + failScaffolderTest('Validation did not report a legacy actor without changing its source. ' . $legacyValidation['output']); + } + + $migration = validateScaffoldedProject($projectRoot, ['--migrate-actor-ids']); + $migratedSource = (string) file_get_contents($heroPath); + $migratedHero = require $heroPath; + if ($migration['code'] !== 0 + || ! str_contains($migration['output'], 'Added stable ids to 1 actor(s)') + || ($migratedHero['data']['id'] ?? null) !== 'Hero' + || ! str_contains($migratedSource, '// Keep this authored comment.')) { + failScaffolderTest('Explicit migration did not preserve authored source and identity. ' . $migration['output']); + } + + $secondMigration = validateScaffoldedProject($projectRoot, ['--migrate-actor-ids']); + if ($secondMigration['code'] !== 0 || file_get_contents($heroPath) !== $migratedSource) { + failScaffolderTest('The actor id migration was not idempotent. ' . $secondMigration['output']); + } + + $malformedSource = str_replace("'id' => 'Hero'", "'id' => ''", $migratedSource); + file_put_contents($heroPath, $malformedSource); + $malformed = validateScaffoldedProject($projectRoot, ['--migrate-actor-ids']); + if ($malformed['code'] === 0 || file_get_contents($heroPath) !== $malformedSource) { + failScaffolderTest('An authored empty actor id was silently repaired. ' . $malformed['output']); + } + if (str_contains($inputSource, "'notify' =>")) { failScaffolderTest('New projects expose the development notification action to players.'); } diff --git a/tests/play-launch-context.php b/tests/play-launch-context.php new file mode 100644 index 0000000..e5524fd --- /dev/null +++ b/tests/play-launch-context.php @@ -0,0 +1,324 @@ +isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($root); +} + +/** @return array{code: int, output: string} */ +function runLaunchContextTmuxCli(string $project, string $json): array +{ + $pipes = []; + $process = proc_open([PHP_BINARY, '-d', 'memory_limit=512M', '-d', 'sys_temp_dir=' . sys_get_temp_dir(), + dirname(__DIR__) . '/bin/ichiloto', 'play', '--directory=' . $project, '--renderer=terminal', + '--no-interaction', '--launch-context=' . $json], + [0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], $pipes, $project); + if (!is_resource($process)) { throw new RuntimeException('Could not start the synthetic tmux CLI.'); } + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $output = ''; + $deadline = microtime(true) + 10; + try { + do { + $output .= (string) @stream_get_contents($pipes[1]); + $output .= (string) stream_get_contents($pipes[2]); + $status = proc_get_status($process); + if (!$status['running']) { break; } + if (microtime(true) >= $deadline) { throw new RuntimeException('Synthetic tmux CLI timed out.'); } + usleep(10000); + } while (true); + $output .= (string) @stream_get_contents($pipes[1]); + $output .= (string) stream_get_contents($pipes[2]); + } finally { + if (proc_get_status($process)['running']) { proc_terminate($process, 9); } + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($process); + } + return ['code' => $code < 0 ? $status['exitcode'] : $code, 'output' => $output]; +} + +$root = sys_get_temp_dir() . '/console-launch-context-' . bin2hex(random_bytes(12)); +mkdir($root, 0700); +$root = realpath($root); +$project = $root . "/project with 'quotes' and spaces"; +$dataRoot = $root . "/isolated 'data'"; +mkdir($project . '/vendor', 0700, true); +mkdir($project . '/.data', 0700); +mkdir($dataRoot, 0700); +$environmentNames = ['ICHILOTO_START_MODE', 'ICHILOTO_LAUNCH_CONTEXT', 'ICHILOTO_RENDERER', 'TMUX', 'PATH', 'FIXTURE_GAME_EXIT']; +$savedEnvironment = []; +foreach ($environmentNames as $name) { + $savedEnvironment[$name] = getenv($name); + if ($name !== 'PATH') { putenv($name); } +} +putenv('ICHILOTO_RENDERER=parent-renderer'); +$autoload = $project . '/vendor/autoload.php'; +$consoleAutoload = dirname(__DIR__) . '/vendor/autoload.php'; +$autoloadSource = ' getenv('ICHILOTO_LAUNCH_CONTEXT'), 'start' => getenv('ICHILOTO_START_MODE'), + 'renderer' => getenv('ICHILOTO_RENDERER'), 'directory' => getcwd(), + 'errorLog' => ini_get('error_log'), 'diagnostic' => getenv('ICHILOTO_TERMINAL_DIAGNOSTIC_LOG'), +], JSON_THROW_ON_ERROR)); +fwrite(STDERR, "synthetic game stderr\n"); +error_log('synthetic PHP error_log'); +if (($code = getenv('FIXTURE_GAME_EXIT')) !== false) { exit((int) $code); } +PHP; +file_put_contents($main, $mainSource); +$config = json_encode(['format' => ProjectFormat::CURRENT, 'main' => basename($main), + 'audio' => ['music' => false, 'soundEffects' => false, 'voice' => false, 'masterVolume' => 0]], JSON_THROW_ON_ERROR); +file_put_contents($project . '/ichiloto.json', $config); +file_put_contents($project . '/.data/save.iedata', 'normal save sentinel'); +file_put_contents($project . '/.data/player-settings.json', 'normal settings sentinel'); +file_put_contents($dataRoot . '/player-settings.json', 'isolated settings sentinel'); +$canRunPosix = PHP_OS_FAMILY !== 'Windows' && function_exists('pcntl_signal') && function_exists('posix_kill'); +$windowCalls = 0; +$forwarded = ''; +$window = new TerminalSessionWindow(platform: 'Windows', + commandRunner: static function (array $arguments, array $descriptors, array $options) use (&$windowCalls, $root) { + $windowCalls++; + assertLaunchContext(getenv('ICHILOTO_LAUNCH_CONTEXT') === false, 'Context leaked into the window opener.'); + return TerminalSessionProcess::start([PHP_BINARY, '-d', 'memory_limit=512M', '-d', 'sys_temp_dir=' . $root, + __FILE__, '--headless-worker', ...array_slice($arguments, -3)], + [0 => ['file', '/dev/null', 'r'], 1 => ['file', $root . '/worker.log', 'a'], 2 => ['file', $root . '/worker.log', 'a']]); + }, findExecutable: static fn(string $name): ?string => $name); +$terminal = new TerminalSessionLauncher($window, $root, + outputWriter: static function (string $message) use (&$forwarded): void { $forwarded .= $message; }); +$command = new PlayCommand( + rendererUpdateChecker: new SourceRendererUpdateChecker(locateEngine: static fn(string $path): string => $path), + terminalSession: $terminal, +); +$tester = new CommandTester($command); +$run = static function (array $options) use ($tester, $project, $capture): int { + if (is_file($capture)) { unlink($capture); } + return $tester->execute(['--directory' => $project, '--no-tmux' => true, '--renderer' => 'terminal', ...$options], ['interactive' => false]); +}; +$readCapture = static fn(): array => json_decode((string) file_get_contents($capture), true, flags: JSON_THROW_ON_ERROR); +$encode = static fn(array $context): string => json_encode($context, JSON_THROW_ON_ERROR); +$errorLog = $dataRoot . '/logs/error.log'; +$builder = new GameLaunchCommandBuilder(); + +try { + assertLaunchContext($command->getDefinition()->getOption('launch-context')->isValueRequired(), 'Context is not a required-value option.'); + foreach (['{}', '{"mode":"title"}', '{"mode":"new-game","start":{"mapId":"synthetic/field","x":2,"y":3}}', + '{"mode":"new-game","start":{"mapId":"synthetic/field","x":2,"y":3},"cinematicId":"test-scene"}', + '{"battleTest":{}}', '{"battleTest":{"troop":"Synthetic troop","arena":"Synthetic arena"}}', + '{}' . str_repeat(' ', 65534)] as $json) { + assertLaunchContext($run(['--launch-context' => $json]) === Command::SUCCESS, 'Valid real-Engine context refused: ' . $tester->getDisplay()); + $captured = $readCapture(); + assertLaunchContext($captured['context'] === $json && $captured['start'] === false + && $captured['directory'] === $project && $captured['renderer'] === 'terminal', 'Context transport changed or lost intent.'); + } + foreach (['', '[]', 'null', 'true', '"text"', '{', '{}' . str_repeat(' ', 65535), + '{"unknown":true}', '{"mode":"continue"}', '{"mode":null}', '{"dataRoot":null}', + '{"start":[]}', '{"battleTest":[]}', '{"battleTest":null}', '{"start":{"mapId":"map","x":0,"y":0}}', + '{"mode":"new-game","start":{"mapId":"../map","x":0,"y":0}}', + '{"mode":"new-game","start":{"mapId":"map","x":1.5,"y":0}}', + '{"cinematicId":"test"}', '{"mode":"title","battleTest":{}}'] as $json) { + assertLaunchContext($run(['--launch-context' => $json]) === Command::INVALID && !is_file($capture), + 'Invalid context launched: ' . $json); + } + assertLaunchContext($run(['--launch-context' => '{}', '--start' => 'title']) === Command::INVALID + && !is_file($capture) && str_contains($tester->getDisplay(), 'cannot be combined'), 'Competing --start launched.'); + foreach (['title', ''] as $inheritedMode) { + putenv('ICHILOTO_START_MODE=' . $inheritedMode); + assertLaunchContext($run(['--launch-context' => '{}']) === Command::INVALID && !is_file($capture), 'Inherited startup mode launched with context.'); + } + putenv('ICHILOTO_START_MODE'); + putenv('ICHILOTO_LAUNCH_CONTEXT={"mode":"title"}'); + assertLaunchContext($run([]) === Command::INVALID && !is_file($capture) + && str_contains($tester->getDisplay(), '--launch-context'), 'Inherited-only launch intent silently reached an unchecked Engine.'); + assertLaunchContext(!isset($builder->buildEnvironment('terminal')['ICHILOTO_LAUNCH_CONTEXT']), 'Shared builder inherited unchecked context.'); + putenv('ICHILOTO_LAUNCH_CONTEXT'); + + file_put_contents($autoload, ' $encode(['dataRoot' => $dataRoot])]) === Command::INVALID + && !is_file($capture) && !is_dir($dataRoot . '/logs') + && str_contains($tester->getDisplay(), 'Update its Engine'), 'Old project Engine ignored isolated launch intent or wrote logs.'); + assertLaunchContext($run([]) === Command::SUCCESS && $readCapture()['context'] === false, 'Ordinary play on an old Engine regressed.'); + file_put_contents($autoload, $autoloadSource . ' echo "unexpected output";'); + assertLaunchContext($run(['--launch-context' => '{}']) === Command::INVALID && !is_file($capture) + && str_contains($tester->getDisplay(), 'invalid launch-context support result'), 'Noisy support result was trusted.'); + file_put_contents($autoload, $autoloadSource); + + mkdir($root . '/foreign', 0700); + file_put_contents($root . '/foreign/keep.txt', 'foreign cache sentinel'); + foreach (['', '/', 'relative-root', $root . '/missing', $project, $project . '/.data', $root, + $dataRoot . '/', $dataRoot . "/nul\0"] as $path) { + assertLaunchContext($run(['--launch-context' => $encode(['dataRoot' => $path])]) === Command::INVALID + && !is_file($capture), 'Unsafe dataRoot launched: ' . $path); + } + file_put_contents($dataRoot . '/save.iedata', 'not a fresh data root'); + assertLaunchContext($run(['--launch-context' => $encode(['dataRoot' => $dataRoot])]) === Command::INVALID && !is_file($capture), 'Nonfresh dataRoot launched.'); + unlink($dataRoot . '/save.iedata'); + file_put_contents($dataRoot . '/logs', 'not a directory'); + assertLaunchContext($run(['--launch-context' => $encode(['dataRoot' => $dataRoot])]) === Command::INVALID && !is_file($capture), 'Regular-file logs path launched.'); + unlink($dataRoot . '/logs'); + if (PHP_OS_FAMILY !== 'Windows') { + chmod($dataRoot, 0500); + assertLaunchContext($run(['--launch-context' => $encode(['dataRoot' => $dataRoot])]) === Command::INVALID && !is_file($capture), 'Nonwritable dataRoot launched.'); + chmod($dataRoot, 0700); + symlink($dataRoot, $root . '/linked-root'); + assertLaunchContext($run(['--launch-context' => $encode(['dataRoot' => $root . '/linked-root'])]) === Command::INVALID && !is_file($capture), 'Symlink root launched.'); + symlink($root . '/foreign', $dataRoot . '/logs'); + assertLaunchContext($run(['--launch-context' => $encode(['dataRoot' => $dataRoot])]) === Command::INVALID && !is_file($capture), 'Symlink logs launched.'); + unlink($dataRoot . '/logs'); + mkdir($dataRoot . '/logs', 0700); + symlink($root . '/foreign/keep.txt', $errorLog); + assertLaunchContext($run(['--launch-context' => $encode(['dataRoot' => $dataRoot])]) === Command::INVALID && !is_file($capture), 'Symlink error log launched.'); + unlink($errorLog); + symlink($root . '/missing-log', $errorLog); + assertLaunchContext($run(['--launch-context' => $encode(['dataRoot' => $dataRoot])]) === Command::INVALID && !is_file($capture), 'Dangling error-log symlink launched.'); + unlink($errorLog); + unlink($dataRoot . '/player-settings.json'); + symlink($root . '/foreign/keep.txt', $dataRoot . '/player-settings.json'); + assertLaunchContext($run(['--launch-context' => $encode(['dataRoot' => $dataRoot])]) === Command::INVALID && !is_file($capture), 'Symlink settings launched.'); + unlink($dataRoot . '/player-settings.json'); + file_put_contents($dataRoot . '/player-settings.json', 'isolated settings sentinel'); + } else { + mkdir($dataRoot . '/logs', 0700); + } + mkdir($errorLog, 0700); + assertLaunchContext($run(['--launch-context' => $encode(['dataRoot' => $dataRoot])]) === Command::INVALID && !is_file($capture), 'Non-file initial error log launched.'); + rmdir($errorLog); + mkdir($dataRoot . '/logs/nested', 0700); + assertLaunchContext($run(['--launch-context' => $encode(['dataRoot' => $dataRoot])]) === Command::INVALID && !is_file($capture), 'Nested initial logs launched.'); + rmdir($dataRoot . '/logs/nested'); + file_put_contents($dataRoot . '/logs/playtest.log', 'editor log sentinel'); + $projectLogBefore = file_get_contents($project . '/logs/error.log'); + $json = $encode(['mode' => 'new-game', 'dataRoot' => $dataRoot]); + assertLaunchContext($run(['--launch-context' => $json]) === Command::SUCCESS, 'Isolated context refused: ' . $tester->getDisplay()); + assertLaunchContext($readCapture()['errorLog'] === $errorLog && $readCapture()['context'] === $json, 'Direct PHP log/context lost isolation.'); + assertLaunchContext(str_contains(file_get_contents($errorLog), 'synthetic game stderr') + && str_contains(file_get_contents($errorLog), 'synthetic PHP error_log'), 'Direct stderr and PHP log were not routed together.'); + $callsBefore = $windowCalls; + assertLaunchContext($run(['--launch-context' => $json, '--renderer' => 'gpui', '--terminal-window' => true]) === Command::SUCCESS + && $readCapture()['renderer'] === 'gpui' && $readCapture()['context'] === $json + && $readCapture()['errorLog'] === $errorLog && $windowCalls === $callsBefore, 'Graphical selection changed context isolation or opened a Terminal window.'); + + if ($canRunPosix) { + file_put_contents($errorLog, ''); + assertLaunchContext($run(['--launch-context' => $json, '--terminal-window' => true]) === Command::SUCCESS, + 'Headless Terminal transport refused: ' . $tester->getDisplay() . $forwarded); + assertLaunchContext($readCapture()['diagnostic'] === $errorLog && $readCapture()['errorLog'] === $errorLog + && $readCapture()['context'] === $json && $readCapture()['start'] === false, 'Terminal lost isolated log/context intent.'); + assertLaunchContext(str_contains(file_get_contents($errorLog), 'synthetic game stderr') + && str_contains(file_get_contents($errorLog), 'synthetic PHP error_log') + && str_contains($forwarded, 'synthetic game stderr') && str_contains($forwarded, 'synthetic PHP error_log'), 'Terminal stderr/PHP diagnostics did not reach the owner.'); + assertLaunchContext(!str_contains(file_get_contents($root . '/worker.log'), 'synthetic game stderr'), 'Terminal stderr still went to the window.'); + assertLaunchContext(glob($root . '/ichiloto-terminal-*') === [], 'Terminal left owned scratch behind.'); + putenv('FIXTURE_GAME_EXIT=23'); + assertLaunchContext($run(['--launch-context' => $json, '--terminal-window' => true]) === Command::FAILURE + && str_contains($tester->getDisplay(), 'exited unexpectedly'), 'Terminal child failure was lost.'); + putenv('FIXTURE_GAME_EXIT'); + $callsBefore = $windowCalls; + file_put_contents($autoload, ' $json, '--terminal-window' => true]) === Command::INVALID + && !is_file($capture) && $windowCalls === $callsBefore, 'Old Engine opened a window before refusal.'); + file_put_contents($autoload, $autoloadSource); + } + + if (PHP_OS_FAMILY !== 'Windows') { + mkdir($root . '/bin', 0700); + $fakeTmux = '#!' . PHP_BINARY . "\n" . <<<'PHP' +run([PHP_BINARY, '-d', 'memory_limit=512M', '-d', 'sys_temp_dir=' . $root, + dirname(__DIR__) . '/bin/ichiloto', 'play', '--directory=' . $project, '--no-tmux', '--no-interaction', + '--renderer=terminal', '--launch-context=' . $json], $root); + assertLaunchContext($readCapture()['context'] === $json && $readCapture()['errorLog'] === $errorLog, 'Registered CLI changed the final transport contract.'); + assertLaunchContext(file_get_contents($project . '/ichiloto.json') === $config && file_get_contents($main) === $mainSource + && file_get_contents($project . '/.data/save.iedata') === 'normal save sentinel' + && file_get_contents($project . '/.data/player-settings.json') === 'normal settings sentinel' + && file_get_contents($dataRoot . '/player-settings.json') === 'isolated settings sentinel' + && file_get_contents($dataRoot . '/logs/playtest.log') === 'editor log sentinel' + && file_get_contents($project . '/logs/error.log') === $projectLogBefore + && file_get_contents($root . '/foreign/keep.txt') === 'foreign cache sentinel', 'Launch rewrote source, settings, saves, project logs or foreign cache.'); + assertLaunchContext(getenv('ICHILOTO_LAUNCH_CONTEXT') === false && getenv('ICHILOTO_START_MODE') === false + && getenv('ICHILOTO_RENDERER') === 'parent-renderer', 'Child launch mutated the parent environment.'); + if (!$canRunPosix) { echo "Headless POSIX Terminal worker fixtures skipped on this host.\n"; } + echo "Play launch-context tests passed ($assertions assertions).\n"; +} finally { + foreach ($savedEnvironment as $name => $value) { + putenv($value === false ? $name : $name . '=' . $value); + } + removeLaunchContextFixture($root); +} diff --git a/tests/play-startup.php b/tests/play-startup.php new file mode 100644 index 0000000..6396f92 --- /dev/null +++ b/tests/play-startup.php @@ -0,0 +1,166 @@ +isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($root); +} + +$root = sys_get_temp_dir() . '/console-play-startup-' . bin2hex(random_bytes(12)); +$project = $root . "/project with 'quotes' and spaces"; +mkdir($project . '/vendor', 0700, true); +mkdir($project . '/.data', 0700); +$oldStartup = getenv(GameLaunchCommandBuilder::STARTUP_ENVIRONMENT_VARIABLE); +$oldRenderer = getenv(GameLaunchCommandBuilder::RENDERER_ENVIRONMENT_VARIABLE); +putenv(GameLaunchCommandBuilder::STARTUP_ENVIRONMENT_VARIABLE); +putenv(GameLaunchCommandBuilder::RENDERER_ENVIRONMENT_VARIABLE . '=parent-renderer'); +$autoload = $project . '/vendor/autoload.php'; +$main = $project . '/game runner.php'; +$capture = $project . '/captured.json'; +$canRunPosix = PHP_OS_FAMILY !== 'Windows' && function_exists('pcntl_signal') && function_exists('posix_kill'); +$config = json_encode(['format' => ProjectFormat::CURRENT, 'main' => basename($main), + 'audio' => ['music' => false, 'soundEffects' => false, 'voice' => false, 'masterVolume' => 0]], JSON_THROW_ON_ERROR); +file_put_contents($project . '/ichiloto.json', $config); +file_put_contents($project . '/.data/save.iedata', 'preserved normal save'); +$mainSource = <<<'PHP' + getenv('ICHILOTO_RENDERER'), 'start' => getenv('ICHILOTO_START_MODE'), + 'directory' => getcwd(), +], JSON_THROW_ON_ERROR)); +if (($code = getenv('FIXTURE_GAME_EXIT')) !== false) { exit((int) $code); } +PHP; +file_put_contents($main, $mainSource); +$engineEnum = (string) (new ReflectionClass(GameStartupMode::class))->getFileName(); +$report = ['schema' => 'ichiloto.renderers/1', 'renderers' => [ + ['id' => 'terminal', 'displayName' => 'Native Terminal', 'available' => true, 'reason' => null], + ['id' => 'gpui', 'displayName' => 'GPUI', 'available' => true, 'reason' => null], +], 'preferredId' => 'gpui']; +$writeAutoload = static function (array $report, bool $startupSupported = true) use ($autoload, $engineEnum): void { + file_put_contents($autoload, ' ['file', '/dev/null', 'r'], 1 => ['file', $root . '/worker.log', 'a'], 2 => ['file', $root . '/worker.log', 'a']]); + }, findExecutable: static fn(string $name): ?string => $name); +$terminal = new TerminalSessionLauncher($window, $root, outputWriter: static function (string $message): void {}); +$tester = new CommandTester(new PlayCommand( + rendererUpdateChecker: new SourceRendererUpdateChecker(locateEngine: static fn(string $path): string => $path), + terminalSession: $terminal, +)); +$run = static function (array $options) use ($tester, $project): int { + return $tester->execute(['--directory' => $project, '--no-tmux' => true, ...$options], ['interactive' => false]); +}; +$readCapture = static fn(): array => json_decode((string) file_get_contents($capture), true, flags: JSON_THROW_ON_ERROR); + +try { + $writeAutoload($report); + $builder = new GameLaunchCommandBuilder(); + foreach (['title', 'new-game'] as $mode) { + assertPlayStartup($run(['--start' => $mode, '--renderer' => 'terminal']) === Command::SUCCESS, + 'Explicit startup refused: ' . $tester->getDisplay()); + assertPlayStartup($readCapture() === ['renderer' => 'terminal', 'start' => $mode, 'directory' => $project], + 'Child lost explicit startup or project working directory.'); + if (PHP_OS_FAMILY !== 'Windows') { + $command = $builder->buildGameCommand($main, $project . '/logs/error.log', 'terminal', $mode); + $pipes = []; + $process = proc_open(['/bin/sh', '-c', $command], [STDIN, STDOUT, STDERR], $pipes, $project); + assertPlayStartup(is_resource($process) && proc_close($process) === 0 && $readCapture()['start'] === $mode, + 'The actual tmux-shaped shell command lost startup or quoted paths.'); + } + } + unlink($capture); + assertPlayStartup($run(['--start' => 'continue']) === Command::INVALID && !is_file($capture), + 'Invalid startup launched a game.'); + assertPlayStartup(str_contains($tester->getDisplay(), 'title or new-game'), 'Invalid startup lacks a useful diagnostic.'); + $writeAutoload($report, false); + assertPlayStartup($run(['--start' => 'new-game']) === Command::INVALID && !is_file($capture) + && str_contains($tester->getDisplay(), 'does not support explicit'), 'An old Engine silently ignored startup.'); + assertPlayStartup($run(['--renderer' => 'terminal']) === Command::SUCCESS && $readCapture()['start'] === false, + 'Ordinary play no longer supports an Engine without explicit startup.'); + $writeAutoload($report); + assertPlayStartup($run(['--start' => 'new-game', '--renderer' => 'auto', '--terminal-window' => true]) === Command::SUCCESS + && $readCapture()['renderer'] === 'gpui' && $windowCalls === 0, 'Auto GPUI incorrectly opened a Terminal window.'); + $report['renderers'][1]['available'] = false; + $report['renderers'][1]['reason'] = 'Not installed'; + $report['preferredId'] = 'terminal'; + $writeAutoload($report); + assertPlayStartup($run(['--start' => 'new-game', '--renderer' => 'auto', '--terminal-window' => $canRunPosix]) === Command::SUCCESS + && $readCapture()['renderer'] === 'terminal' && $readCapture()['start'] === 'new-game' && $windowCalls === ($canRunPosix ? 1 : 0), + 'Auto Terminal fallback did not use an owned window and explicit startup.'); + assertPlayStartup(glob($root . '/ichiloto-terminal-*') === [], 'Terminal launch left owned scratch behind.'); + assertPlayStartup($run(['--start' => 'title', '--renderer' => 'terminal', '--terminal-window' => $canRunPosix]) === Command::SUCCESS + && $readCapture()['start'] === 'title' && $windowCalls === ($canRunPosix ? 2 : 0), 'Explicit Terminal title mode was lost.'); + assertPlayStartup($run(['--renderer' => 'gpui', '--terminal-window' => true]) === Command::SUCCESS + && $readCapture()['renderer'] === 'gpui' && $windowCalls === ($canRunPosix ? 2 : 0), 'Explicit GPUI was silently changed to Terminal.'); + putenv('FIXTURE_GAME_EXIT=23'); + assertPlayStartup($run(['--start' => 'new-game', '--renderer' => 'terminal', '--terminal-window' => $canRunPosix]) === Command::FAILURE + && str_contains($tester->getDisplay(), 'exited unexpectedly'), 'Terminal child failure was reported as success.'); + putenv('FIXTURE_GAME_EXIT'); + file_put_contents($autoload, 'requireModeSupport($project, 'title'); + throw new RuntimeException('Invalid support output was accepted.'); + } catch (RuntimeException $error) { + assertPlayStartup(str_contains($error->getMessage(), 'does not support explicit'), 'Unexpected support refusal.'); + } + $writeAutoload($report); + $process = new RendererPreparationProcess(); + $process->run([PHP_BINARY, '-d', 'memory_limit=512M', dirname(__DIR__) . '/bin/ichiloto', 'play', + '--directory=' . $project, '--no-tmux', '--no-interaction', '--renderer=auto', '--start=new-game'], $root); + assertPlayStartup($readCapture()['start'] === 'new-game' && $readCapture()['renderer'] === 'terminal', + 'The registered Play CLI did not forward the final contract.'); + assertPlayStartup(file_get_contents($project . '/ichiloto.json') === $config + && file_get_contents($project . '/.data/save.iedata') === 'preserved normal save' + && file_get_contents($main) === $mainSource, 'Startup rewrote settings, saves or project entrypoint.'); + assertPlayStartup(getenv(GameLaunchCommandBuilder::STARTUP_ENVIRONMENT_VARIABLE) === false + && getenv(GameLaunchCommandBuilder::RENDERER_ENVIRONMENT_VARIABLE) === 'parent-renderer', 'Child launch changed the parent environment.'); + if (!$canRunPosix) { echo "POSIX terminal-window lifecycle fixtures skipped on this host; direct startup/selection still checked.\n"; } + echo "Play startup and renderer/window integration tests passed.\n"; +} finally { + putenv('FIXTURE_GAME_EXIT'); + putenv($oldStartup === false ? GameLaunchCommandBuilder::STARTUP_ENVIRONMENT_VARIABLE + : GameLaunchCommandBuilder::STARTUP_ENVIRONMENT_VARIABLE . '=' . $oldStartup); + putenv($oldRenderer === false ? GameLaunchCommandBuilder::RENDERER_ENVIRONMENT_VARIABLE + : GameLaunchCommandBuilder::RENDERER_ENVIRONMENT_VARIABLE . '=' . $oldRenderer); + removePlayStartupFixture($root); +} diff --git a/tests/project-format-refusal.php b/tests/project-format-refusal.php new file mode 100644 index 0000000..ed0617f --- /dev/null +++ b/tests/project-format-refusal.php @@ -0,0 +1,86 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + fclose($pipes[0]); + $output = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return ['exitCode' => proc_close($process), 'output' => $output]; +} + +function removeFormatProject(string $directory): void +{ + foreach (scandir($directory) ?: [] as $entry) { + if ($entry !== '.' && $entry !== '..') { + $path = $directory . '/' . $entry; + is_dir($path) ? removeFormatProject($path) : unlink($path); + } + } + rmdir($directory); +} + +$failures = []; + +try { + mkdir($projectRoot . '/vendor', 0777, true); + file_put_contents($projectRoot . '/vendor/autoload.php', " null, 'older' => ProjectFormat::CURRENT - 1, 'newer' => ProjectFormat::CURRENT + 1] as $case => $format) { + $config = ['id' => 'fixture/format-refusal', 'main' => 'main.php'] + ($format === null ? [] : [ProjectFormat::KEY => $format]); + file_put_contents($projectRoot . '/ichiloto.json', json_encode($config) . "\n"); + + try { + ProjectFormat::assertSupported($format); + $failures[] = "{$case}: the engine accepted the format."; + continue; + } catch (Throwable $exception) { + $message = $exception->getMessage(); + } + + foreach (['play' => ['play', '--no-ansi', '--no-tmux', '--directory', $projectRoot], 'validate' => ['validate', '--no-ansi', '--directory', $projectRoot]] as $command => $arguments) { + $result = runFormatCommand($consoleBin, $arguments); + if ($result['exitCode'] === 0 || ! str_contains($result['output'], $message) || file_exists($projectRoot . '/launched')) { + $failures[] = "{$case} {$command} did not stop with the engine's format message: {$result['output']}"; + } + } + } + + file_put_contents($projectRoot . '/ichiloto.json', json_encode(['id' => 'fixture/format-refusal', 'main' => 'main.php', ProjectFormat::KEY => ProjectFormat::CURRENT]) . "\n"); + $current = runFormatCommand($consoleBin, ['play', '--no-ansi', '--no-tmux', '--renderer', 'terminal', '--directory', $projectRoot]); + if (! file_exists($projectRoot . '/launched')) { + $failures[] = 'play did not launch a project at the current format: ' . $current['output']; + } +} finally { + removeFormatProject($projectRoot); +} + +if ($failures !== []) { + fwrite(STDERR, 'FAIL: ' . implode("\nFAIL: ", $failures) . "\n"); + exit(1); +} + +fwrite(STDOUT, "PASS: play and validate stop with the engine's format message before reading an outdated or newer project.\n"); diff --git a/tests/project-startup-identity.php b/tests/project-startup-identity.php new file mode 100644 index 0000000..803a46f --- /dev/null +++ b/tests/project-startup-identity.php @@ -0,0 +1,474 @@ +dismissed) { return null; } + $this->renderedAlert = implode("\n", Console::getBuffer()); + $this->dismissed = true; + return KeyCode::ENTER; + } + + public function reset(bool $drainBufferedInput = false): void {} + + public function getRenderedAlert(): ?string + { + return $this->renderedAlert; + } +} + +/** Real title, loader, field and Engine loop; stops immediately after field startup. */ +final class StartupIdentityProbe extends Game +{ + public string $probeMode = 'field'; + + protected function start(): void + { + parent::start(); + if ($this->probeMode === 'title-recovery') { + $this->verifyNewGameFailureRecoversTitle(); + $this->quit(); + return; + } + $config = GameLoader::getInstance($this)->loadNewGame(); + $scene = $this->sceneManager->loadScene(GameScene::class)->currentScene; + if (! $scene instanceof GameScene) { + throw new RuntimeException('The new game did not enter the field scene.'); + } + $scene->configure($config); + if ($scene->party->members->count() !== 1) { + throw new RuntimeException('The starting actor was not loaded.'); + } + fwrite(STDERR, "FIELD_STARTUP_OK\n"); + $this->quit(); + } + + private function verifyNewGameFailureRecoversTitle(): void + { + $title = $this->sceneManager->currentScene; + if (! $title instanceof TitleScene) { + throw new RuntimeException('New Game did not begin at the title scene.'); + } + $menu = (new ReflectionProperty(TitleScene::class, 'menu'))->getValue($title); + if (! $menu instanceof TitleMenu) { + throw new RuntimeException('The title scene has no active menu.'); + } + $command = array_find($menu->getItems()->toArray(), static fn($item): bool => $item instanceof NewGameCommand); + if (! $command instanceof NewGameCommand) { + throw new RuntimeException('The title menu has no New Game command.'); + } + $capture = new CapturedAlertInputSource(); + InputManager::setInputSource($capture); + $result = $command->execute(new MenuCommandExecutionContext([], new BufferedOutput(), $menu, $title)); + $alert = preg_replace('/\x1b\[[0-9;]*m/', '', $capture->getRenderedAlert() ?? '') ?? ''; + $alert = preg_replace('/[║╔╗╚╝═]/u', ' ', $alert) ?? ''; + $alert = preg_replace('/\s+/u', ' ', $alert) ?? ''; + if ($result !== NewGameCommand::FAILURE + || ! $this->sceneManager->currentScene instanceof TitleScene + || ! $this->isRunning || $this->hasStopped() + || ! str_contains($alert, 'New Game Unavailable') + || ! str_contains($alert, 'assets/Data/system.php') + || ! str_contains($alert, 'AriaVale') + || ! str_contains($alert, 'ichiloto validate --migrate-actor-ids')) { + throw new RuntimeException('New Game did not show a recoverable title alert: ' . $alert); + } + fwrite(STDERR, "TITLE_FAILURE_RECOVERED\n"); + } +} + +/** @return array{code: int, output: string} */ +function runStartupChild(string $projectRoot, string $probeMode = 'field'): array +{ + $process = proc_open( + [PHP_BINARY, __FILE__, '--child', $projectRoot, $probeMode], + [0 => ['pipe', 'r'], 1 => ['file', $projectRoot . '/startup.stdout', 'w'], 2 => ['file', $projectRoot . '/startup.stderr', 'w']], + $pipes, + $projectRoot, + array_replace(getenv(), [ + 'ICHILOTO_RENDERER' => 'terminal', + 'ICHILOTO_TEST_TERMINAL_SIZE' => '36 135', + 'TERM' => 'dumb', + 'COLUMNS' => '135', + 'LINES' => '36', + ]), + ); + if (! is_resource($process)) { + throw new RuntimeException('Could not start bounded Game process.'); + } + fclose($pipes[0]); + $deadline = microtime(true) + STARTUP_TIMEOUT_SECONDS; + do { + $status = proc_get_status($process); + if (! $status['running']) { break; } + usleep(STARTUP_POLL_MICROSECONDS); + } while (microtime(true) < $deadline); + if ($status['running']) { + proc_terminate($process); + proc_close($process); + throw new RuntimeException(sprintf('Bounded Game startup exceeded %d seconds.', STARTUP_TIMEOUT_SECONDS)); + } + $code = $status['exitcode'] >= 0 ? $status['exitcode'] : proc_close($process); + if ($status['exitcode'] >= 0) { proc_close($process); } + $output = (string) file_get_contents($projectRoot . '/startup.stderr'); + if (is_file($projectRoot . '/logs/error.log')) { + $output .= (string) file_get_contents($projectRoot . '/logs/error.log'); + } + return ['code' => $code, 'output' => $output]; +} + +/** @return array{code: int, output: string} */ +function runValidationChild(string $projectRoot, array $options = []): array +{ + $process = proc_open( + [PHP_BINARY, dirname(__DIR__) . '/bin/ichiloto', 'validate', '--no-ansi', '--no-interaction', '--directory', $projectRoot, ...$options], + [0 => ['pipe', 'r'], 1 => ['file', $projectRoot . '/validate.stdout', 'w'], 2 => ['file', $projectRoot . '/validate.stderr', 'w']], + $pipes, + dirname(__DIR__), + ); + if (! is_resource($process)) { + throw new RuntimeException('Could not start bounded project validation.'); + } + fclose($pipes[0]); + $deadline = microtime(true) + STARTUP_TIMEOUT_SECONDS; + do { + $status = proc_get_status($process); + if (! $status['running']) { break; } + usleep(STARTUP_POLL_MICROSECONDS); + } while (microtime(true) < $deadline); + if ($status['running']) { + proc_terminate($process); + proc_close($process); + throw new RuntimeException('Bounded project validation exceeded its time limit.'); + } + $code = $status['exitcode'] >= 0 ? $status['exitcode'] : proc_close($process); + if ($status['exitcode'] >= 0) { proc_close($process); } + return [ + 'code' => $code, + 'output' => (string) file_get_contents($projectRoot . '/validate.stdout') + . (string) file_get_contents($projectRoot . '/validate.stderr'), + ]; +} + +/** Brings a copied older project to the engine's format the way an author would. */ +function upgradeCopiedProject(string $projectRoot): void +{ + $process = proc_open( + [PHP_BINARY, dirname(__DIR__) . '/bin/ichiloto', 'upgrade', '--no-ansi', '--yes', '--directory', $projectRoot], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + dirname(__DIR__), + ); + if (! is_resource($process)) { + throw new RuntimeException('Could not start the project upgrade.'); + } + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) !== 0) { + throw new RuntimeException('The copied project could not be upgraded: ' . $output); + } +} + +function copySmallProjectTree(string $source, string $destination): void +{ + if (! is_dir($destination) && ! mkdir($destination, 0700, true)) { + throw new RuntimeException("Cannot create {$destination}."); + } + foreach (scandir($source) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { continue; } + $from = $source . '/' . $entry; + $to = $destination . '/' . $entry; + if (is_dir($from)) { + copySmallProjectTree($from, $to); + } elseif (! copy($from, $to)) { + throw new RuntimeException("Cannot copy {$from}."); + } + } +} + +function removeStartupFixture(string $root): void +{ + if (! is_dir($root)) { return; } + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() && ! $item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($root); +} + +function makeSilentConfig(string $projectRoot, array $config): void +{ + $config['audio'] = array_replace($config['audio'] ?? [], [ + 'master_volume' => 0, + 'music' => false, + 'sfx' => false, + 'voice' => false, + ]); + file_put_contents($projectRoot . '/config.php', "get($path, $path === 'audio.voice'))) { + throw new RuntimeException("Automated startup requires {$path} to be off."); + } + } + if (class_exists(AudioMutePreflight::class)) { + AudioMutePreflight::assertMuted($effective); + } +} + +/** This must pass before opening any automated Game process. */ +function assertSilentProject(string $projectRoot): void +{ + $previous = getcwd() ?: dirname(__DIR__); + chdir($projectRoot); + try { + if (class_exists(PlayerSettings::class)) { + ConfigStore::put(PlayerSettings::class, new PlayerSettings($projectRoot)); + } elseif (is_file($projectRoot . '/.data/player-settings.json')) { + throw new RuntimeException('Cannot verify a saved player-settings override with this Engine version.'); + } + assertMutedConfig(new ProjectConfig()); + } finally { + if (class_exists(PlayerSettings::class)) { ConfigStore::remove(PlayerSettings::class); } + chdir($previous); + } +} + +if (($argv[1] ?? null) === '--child') { + $projectRoot = $argv[2] ?? ''; + assertStartupEngineApisAreAvailable(); + assertSilentProject($projectRoot); + chdir($projectRoot); + $game = new StartupIdentityProbe('Identity startup check'); + $game->probeMode = $argv[3] ?? 'field'; + $effectiveConfig = ConfigStore::get(ProjectConfig::class); + if (! $effectiveConfig instanceof ProjectConfig) { + throw new RuntimeException('Game did not load the effective project configuration.'); + } + assertMutedConfig($effectiveConfig); + $game->run(); + fwrite(STDERR, "GAME_SHUTDOWN_OK\n"); + exit(0); +} + +$temporaryRoot = sys_get_temp_dir() . '/ichiloto-startup-identity-' . bin2hex(random_bytes(8)); +$freshRoot = $temporaryRoot . '/fresh'; +$epicRoot = $temporaryRoot . '/epic-quest'; +$releasedRoot = $temporaryRoot . '/released-0.5.0'; +$partialRoot = $temporaryRoot . '/partially-migrated-0.5.0'; +$unsupportedRoot = $temporaryRoot . '/unsupported-released-0.5.0'; +$epicFixture = getenv('ICHILOTO_EPIC_QUEST_SRC'); +$epicSource = is_string($epicFixture) && $epicFixture !== '' ? realpath($epicFixture) : false; + +assertStartupEngineApisAreAvailable(); + +try { + new NewProjectScaffolder()->scaffold([ + 'displayName' => 'Startup Identity', + 'directoryName' => 'startup-identity', + 'targetDirectory' => $freshRoot, + 'heroName' => 'Aria Vale', + 'heroId' => 'AriaVale', + 'battleEngine' => 'traditional', + 'titleArt' => "STARTUP IDENTITY\n", + ]); + makeSilentConfig($freshRoot, require $freshRoot . '/config.php'); + assertSilentProject($freshRoot); + $fresh = runStartupChild($freshRoot); + if ($fresh['code'] !== 0 || ! str_contains($fresh['output'], 'FIELD_STARTUP_OK') + || ! str_contains($fresh['output'], 'GAME_SHUTDOWN_OK') || str_contains($fresh['output'], '[ERROR]')) { + throw new RuntimeException('Fresh project did not reach field startup: ' . $fresh['output']); + } + + fwrite(STDOUT, "PASS: fresh project reaches real field startup with effective audio muted.\n"); + + // Generated by the actual Console 0.5.0 tag a92ce3c, not the current scaffolder, + // so each copy is brought to the current project format first. + $releasedSource = __DIR__ . '/fixtures/released-console-0.5.0-project'; + copySmallProjectTree($releasedSource, $releasedRoot); + upgradeCopiedProject($releasedRoot); + $legacyActorPath = $releasedRoot . '/assets/Data/Actors/AriaVale.php'; + $legacySystemPath = $releasedRoot . '/assets/Data/system.php'; + $legacyActor = require $legacyActorPath; + $legacySystem = require $legacySystemPath; + if (array_key_exists('id', $legacyActor['data']) + || $legacyActor['data']['name'] !== 'Aria Vale' + || $legacySystem['startingParty'] !== ['AriaVale']) { + throw new RuntimeException('The committed fixture no longer matches the released Console 0.5.0 actor mismatch.'); + } + makeSilentConfig($releasedRoot, require $releasedRoot . '/config.php'); + assertSilentProject($releasedRoot); + $legacyStartup = runStartupChild($releasedRoot); + if ($legacyStartup['code'] !== 0 || ! str_contains($legacyStartup['output'], 'FIELD_STARTUP_OK') + || ! str_contains($legacyStartup['output'], 'GAME_SHUTDOWN_OK')) { + throw new RuntimeException('The released project could not start a new game before migration: ' . $legacyStartup['output']); + } + $legacyValidation = runValidationChild($releasedRoot); + if ($legacyValidation['code'] === 0 + || ! str_contains($legacyValidation['output'], 'AriaVale.php') + || ! str_contains($legacyValidation['output'], 'startingParty')) { + throw new RuntimeException('Validation did not identify the released actor file and stale starting party: ' . $legacyValidation['output']); + } + $migrated = runValidationChild($releasedRoot, ['--migrate-actor-ids']); + $migratedActor = require $legacyActorPath; + $migratedSystem = require $legacySystemPath; + if ($migrated['code'] !== 0 + || ($migratedActor['data']['id'] ?? null) !== 'Aria Vale' + || $migratedSystem['startingParty'] !== ['Aria Vale'] + || ! str_contains($migrated['output'], 'AriaVale.php') + || ! str_contains($migrated['output'], 'system.php')) { + throw new RuntimeException('Migration did not repair the released actor and starting party: ' . $migrated['output']); + } + $actorAfterMigration = (string) file_get_contents($legacyActorPath); + $systemAfterMigration = (string) file_get_contents($legacySystemPath); + $again = runValidationChild($releasedRoot, ['--migrate-actor-ids']); + if ($again['code'] !== 0 || file_get_contents($legacyActorPath) !== $actorAfterMigration + || file_get_contents($legacySystemPath) !== $systemAfterMigration) { + throw new RuntimeException('The released project migration was not idempotent: ' . $again['output']); + } + assertSilentProject($releasedRoot); + $migratedStartup = runStartupChild($releasedRoot); + if ($migratedStartup['code'] !== 0 || ! str_contains($migratedStartup['output'], 'FIELD_STARTUP_OK') + || ! str_contains($migratedStartup['output'], 'GAME_SHUTDOWN_OK')) { + throw new RuntimeException('The released project could not start a new game after migration: ' . $migratedStartup['output']); + } + fwrite(STDOUT, "PASS: released Console 0.5.0 project starts before and after source-preserving actor migration.\n"); + + copySmallProjectTree($releasedSource, $partialRoot); + upgradeCopiedProject($partialRoot); + $partialActorPath = $partialRoot . '/assets/Data/Actors/AriaVale.php'; + $partialSystemPath = $partialRoot . '/assets/Data/system.php'; + $partialActorSource = (string) file_get_contents($partialActorPath); + $partialActorSource = str_replace( + " 'name' => 'Aria Vale',", + " 'id' => 'Aria Vale',\n 'name' => 'Aria Vale',", + $partialActorSource, + $insertedIds, + ); + if ($insertedIds !== 1) { + throw new RuntimeException('Could not model a partially migrated released actor.'); + } + file_put_contents($partialActorPath, $partialActorSource); + makeSilentConfig($partialRoot, require $partialRoot . '/config.php'); + assertSilentProject($partialRoot); + $partialBeforeRepair = runStartupChild($partialRoot); + if ($partialBeforeRepair['code'] === 0 + || str_contains($partialBeforeRepair['output'], 'FIELD_STARTUP_OK') + || ! str_contains($partialBeforeRepair['output'], 'AriaVale')) { + throw new RuntimeException('A modern actor id unexpectedly accepted its stale file-stem reference: ' . $partialBeforeRepair['output']); + } + $recoveredTitle = runStartupChild($partialRoot, 'title-recovery'); + if ($recoveredTitle['code'] !== 0 || ! str_contains($recoveredTitle['output'], 'TITLE_FAILURE_RECOVERED') + || str_contains($recoveredTitle['output'], 'FIELD_STARTUP_OK')) { + throw new RuntimeException('The released project did not recover a failed New Game at its title: ' . $recoveredTitle['output']); + } + $partialValidation = runValidationChild($partialRoot); + if ($partialValidation['code'] === 0 || ! str_contains($partialValidation['output'], 'startingParty')) { + throw new RuntimeException('Validation missed an already identified actor with a stale party reference: ' . $partialValidation['output']); + } + $partialMigration = runValidationChild($partialRoot, ['--migrate-actor-ids']); + $partialSystem = require $partialSystemPath; + if ($partialMigration['code'] !== 0 || $partialSystem['startingParty'] !== ['Aria Vale'] + || file_get_contents($partialActorPath) !== $partialActorSource) { + throw new RuntimeException('Migration did not repair a previously frozen actor reference: ' . $partialMigration['output']); + } + assertSilentProject($partialRoot); + $partialStartup = runStartupChild($partialRoot); + if ($partialStartup['code'] !== 0 || ! str_contains($partialStartup['output'], 'FIELD_STARTUP_OK') + || ! str_contains($partialStartup['output'], 'GAME_SHUTDOWN_OK')) { + throw new RuntimeException('The partially migrated project could not start after reference repair: ' . $partialStartup['output']); + } + fwrite(STDOUT, "PASS: stale released party references show a recoverable title alert, then migrate cleanly.\n"); + + copySmallProjectTree($releasedSource, $unsupportedRoot); + upgradeCopiedProject($unsupportedRoot); + $unsupportedActorPath = $unsupportedRoot . '/assets/Data/Actors/AriaVale.php'; + $unsupportedSystemPath = $unsupportedRoot . '/assets/Data/system.php'; + $unsupportedSystemSource = (string) file_get_contents($unsupportedSystemPath); + $unsupportedActorSource = (string) file_get_contents($unsupportedActorPath); + $unsupportedActorSource = str_replace('return [', '$actor = [', $unsupportedActorSource, $convertedReturns); + if ($convertedReturns !== 1) { + throw new RuntimeException('Could not model a dynamically returned released actor.'); + } + file_put_contents($unsupportedActorPath, $unsupportedActorSource . "\nreturn \$actor;\n"); + $unsupportedMigration = runValidationChild($unsupportedRoot, ['--migrate-actor-ids']); + if ($unsupportedMigration['code'] === 0 + || ! str_contains($unsupportedMigration['output'], 'AriaVale.php') + || file_get_contents($unsupportedActorPath) !== $unsupportedActorSource . "\nreturn \$actor;\n" + || file_get_contents($unsupportedSystemPath) !== $unsupportedSystemSource) { + throw new RuntimeException('The CLI did not refuse unsupported actor source with its filename: ' . $unsupportedMigration['output']); + } + fwrite(STDOUT, "PASS: migration refusal names the unsupported released actor file.\n"); + + if ($epicFixture === false || $epicFixture === '') { + fwrite(STDOUT, "SKIP: set ICHILOTO_EPIC_QUEST_SRC to check EpicQuest field startup.\n"); + } else { + if ($epicSource === false || ! is_file($epicSource . '/ichiloto.json') + || ! is_file($epicSource . '/config.php') || ! is_file($epicSource . '/input.php') + || ! is_dir($epicSource . '/assets')) { + throw new RuntimeException('ICHILOTO_EPIC_QUEST_SRC does not name a complete project.'); + } + mkdir($epicRoot, 0700, true); + copySmallProjectTree($epicSource . '/assets', $epicRoot . '/assets'); + copy($epicSource . '/input.php', $epicRoot . '/input.php'); + copy($epicSource . '/ichiloto.json', $epicRoot . '/ichiloto.json'); + upgradeCopiedProject($epicRoot); + makeSilentConfig($epicRoot, require $epicSource . '/config.php'); + assertSilentProject($epicRoot); + $epic = runStartupChild($epicRoot); + if ($epic['code'] !== 0 || ! str_contains($epic['output'], 'FIELD_STARTUP_OK') + || ! str_contains($epic['output'], 'GAME_SHUTDOWN_OK') || str_contains($epic['output'], '[ERROR]')) { + throw new RuntimeException('EpicQuest copy did not reach field startup: ' . $epic['output']); + } + fwrite(STDOUT, "PASS: EpicQuest project reaches real field startup with effective audio muted.\n"); + } +} finally { + removeStartupFixture($temporaryRoot); +} diff --git a/tests/renderer-availability.php b/tests/renderer-availability.php new file mode 100644 index 0000000..371c87a --- /dev/null +++ b/tests/renderer-availability.php @@ -0,0 +1,227 @@ +getMessage(), $message), 'Unexpected failure: ' . $error->getMessage()); + return; + } + throw new RuntimeException('Expected discovery failure: ' . $message); +} + +function getAvailabilitySnapshot(string $directory): array +{ + $snapshot = []; + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $snapshot[$file->getPathname()] = $file->isLink() ? readlink($file->getPathname()) : hash_file('sha256', $file->getPathname()); + } + ksort($snapshot); + return $snapshot; +} + +function getReadOnlyAvailabilityReport(ProjectRendererAvailability $availability, string $directory, string $root): array +{ + $before = getAvailabilitySnapshot($root); + try { return $availability->getReport($directory); } finally { + assertAvailability(getAvailabilitySnapshot($root) === $before, 'Discovery changed project or installation files.'); + } +} + +function removeAvailabilityDirectory(string $directory): void +{ + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() && ! $file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($directory); +} + +function writeAvailabilityReportFixture(string $autoload, mixed $report): void +{ + file_put_contents($autoload, 'getFileName(), + $engine . '/src/Rendering/Launch/' . $class . '.php'); + } + $projectAutoload = ' 1, 'renderers' => ['gpui' => [$platform => 'renderer']]], JSON_THROW_ON_ERROR)); + $missing = getReadOnlyAvailabilityReport($availability, $project, $root); + assertAvailability(! $missing['renderers'][1]['available'] && str_contains($missing['renderers'][1]['reason'], 'missing or not executable'), + 'Missing installed executable was not diagnosed.'); + $marker = $project . '/native-launched'; + file_put_contents($installed . '/renderer', "#!/bin/sh\ntouch " . escapeshellarg($marker) . "\n"); + chmod($installed . '/renderer', 0755); + $available = getReadOnlyAvailabilityReport($availability, $project, $root); + assertAvailability($available['preferredId'] === 'gpui' && $available['renderers'][1]['available'] + && $available['renderers'][1]['reason'] === null && ! is_file($marker), 'Available GPUI was launched or not preferred.'); + chmod($installed . '/renderer', 0644); + assertAvailability(! getReadOnlyAvailabilityReport($availability, $project, $root)['renderers'][1]['available'], 'Non-executable GPUI was offered.'); + unlink($installed . '/renderer'); + symlink(PHP_BINARY, $installed . '/renderer'); + assertAvailability(! getReadOnlyAvailabilityReport($availability, $project, $root)['renderers'][1]['available'], 'Escaped executable link was offered.'); + + $neverPrompt = static fn(): never => throw new RuntimeException('Explicit auto or headless default prompted.'); + $selector = new RendererSelector(new RendererRegistry(), $neverPrompt); + assertAvailability($selector->resolve(' auto ', false, true, $available)->id === 'gpui', 'Auto did not select available GPUI.'); + assertAvailability($selector->resolve('auto', false, false, $absent)->id === 'terminal', 'Auto did not select Terminal when GPUI was absent.'); + assertAvailability($selector->resolve(null, false, false, $available)->id === 'terminal', 'Ordinary headless default changed.'); + assertAvailability($selector->resolve('gpui', false, false, $absent)->id === 'gpui', 'Explicit unavailable GPUI silently became Terminal.'); + assertAvailability($selector->resolve(null, true, false, $absent)->id === 'gpui', 'The explicit GPUI alias silently became Terminal.'); + assertAvailability($selector->resolve('auto', true, false, $available)->id === 'gpui', 'Agreeing auto and GPUI alias failed.'); + expectAvailabilityFailure(static fn() => $selector->resolve('auto', false, false), 'requires a project renderer availability report'); + expectAvailabilityFailure(static fn() => $selector->resolve('auto', true, false, $absent), 'conflicts'); + expectAvailabilityFailure(static fn() => $selector->resolve('unknown', false, false, $available), 'Unknown renderer'); + $promptCalls = 0; + $prompted = new RendererSelector(new RendererRegistry(), static function (string $label, array $options) use (&$promptCalls): string { + $promptCalls++; + assertAvailability($options === ['terminal' => 'Native Terminal', 'gpui' => 'GPUI'], 'Normal prompt options changed.'); + return 'terminal'; + }); + assertAvailability($prompted->resolve(null, false, true, $available)->id === 'terminal' && $promptCalls === 1, + 'Ordinary interactive selection changed.'); + + $tester = new CommandTester(new RendererListCommand()); + $snapshot = getAvailabilitySnapshot($root); + $status = $tester->execute(['--directory' => $project, '--json' => true], ['interactive' => true]); + assertAvailability($status === Command::SUCCESS && json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR) + === getReadOnlyAvailabilityReport($availability, $project, $root), 'renderer:list did not return pure contract JSON.'); + assertAvailability(getAvailabilitySnapshot($root) === $snapshot, 'renderer:list changed files.'); + $status = $tester->execute(['--directory' => $project], ['interactive' => false]); + assertAvailability($status === Command::SUCCESS && str_contains($tester->getDisplay(), 'Native Terminal (terminal): available') + && str_contains($tester->getDisplay(), 'GPUI (gpui): unavailable'), 'Human discovery output omitted availability.'); + $cli = [PHP_BINARY, '-d', 'memory_limit=512M', dirname(__DIR__) . '/bin/ichiloto', 'renderer:list', + '--directory=' . $project, '--json', '--no-interaction']; + $response = $runner->run($cli, $root, timeoutSeconds: 5); + assertAvailability(json_decode($response, true, flags: JSON_THROW_ON_ERROR)['preferredId'] === 'terminal', + 'The registered renderer:list CLI failed.'); + expectAvailabilityFailure(static fn() => $runner->run(array_replace($cli, [5 => '--directory=' . $root . '/missing']), $root), + 'Supply a valid project directory'); + + // This project reports a different Engine registry despite Console already loading its own Engine. + $custom = ['schema' => 'ichiloto.renderers/1', 'renderers' => [ + ['id' => 'canvas', 'displayName' => 'Project Canvas', 'available' => true, 'reason' => null], + ], 'preferredId' => 'canvas']; + writeAvailabilityReportFixture($autoload, $custom); + assertAvailability(getReadOnlyAvailabilityReport($availability, $project, $root) === $custom, 'Console substituted its own Engine.'); + $customSelector = new RendererSelector(new RendererRegistry([new RendererDescriptor('canvas', 'Project Canvas')]), $neverPrompt); + assertAvailability($customSelector->resolve('auto', false, false, $custom)->id === 'canvas', 'Custom registry preference was lost.'); + expectAvailabilityFailure(static fn() => $selector->resolve('auto', false, false, $custom), 'Unknown renderer'); + $empty = ['schema' => 'ichiloto.renderers/1', 'renderers' => [], 'preferredId' => null]; + writeAvailabilityReportFixture($autoload, $empty); + assertAvailability(getReadOnlyAvailabilityReport($availability, $project, $root) === $empty, 'Empty valid registry was rejected.'); + expectAvailabilityFailure(static fn() => $selector->resolve('auto', false, false, $empty), 'No renderer is available'); + + $invalidReports = [null, ['schema' => 'future'], $custom + ['extra' => true], array_replace($custom, ['schema' => 'ichiloto.renderers/2']), + array_replace($custom, ['preferredId' => 'gpui']), array_replace($custom, ['preferredId' => null]), + array_replace($custom, ['preferredId' => []]), array_replace($custom, ['renderers' => ['canvas' => $custom['renderers'][0]]]), + array_replace($custom, ['renderers' => [$custom['renderers'][0], $custom['renderers'][0]]])]; + foreach ([['id' => 'UPPERCASE'], ['id' => str_repeat('x', 65)], ['displayName' => ''], ['displayName' => str_repeat('x', 129)], + ['displayName' => "Canvas\x1b"], ['available' => 1], ['reason' => 'Available with reason'], + ['available' => false], ['available' => false, 'reason' => ''], ['available' => false, 'reason' => str_repeat('x', 2049)], + ['extra' => 1]] as $changes) { + $invalidReports[] = array_replace($custom, ['renderers' => [array_replace($custom['renderers'][0], $changes)]]); + } + $tooMany = []; + for ($index = 0; $index < 33; $index++) { $tooMany[] = array_replace($custom['renderers'][0], ['id' => 'canvas' . $index]); } + $invalidReports[] = array_replace($custom, ['renderers' => $tooMany, 'preferredId' => 'canvas0']); + foreach ($invalidReports as $report) { + writeAvailabilityReportFixture($autoload, $report); + expectAvailabilityFailure(static fn() => getReadOnlyAvailabilityReport($availability, $project, $root), 'renderer'); + } + foreach (['[]', '{"schema":"ichiloto.renderers/1","renderers":{},"preferredId":null}', + '{"schema":"ichiloto.renderers/1","renderers":{"canvas":{"id":"canvas","displayName":"Canvas","available":true,"reason":null}},"preferredId":"canvas"}'] as $json) { + file_put_contents($autoload, ' getReadOnlyAvailabilityReport($availability, $project, $root), 'renderer'); + } + file_put_contents($autoload, ' getReadOnlyAvailabilityReport($availability, $project, $root), 'does not support RendererRegistry::getReport'); + file_put_contents($autoload, ' getReadOnlyAvailabilityReport($availability, $project, $root), 'does not support renderer availability'); + file_put_contents($autoload, ' getReadOnlyAvailabilityReport($availability, $project, $root), 'does not support renderer availability'); + writeAvailabilityReportFixture($autoload, $custom); + file_put_contents($autoload, str_replace('final class RendererRegistry', 'echo "{"; final class RendererRegistry', + (string) file_get_contents($autoload))); + expectAvailabilityFailure(static fn() => getReadOnlyAvailabilityReport($availability, $project, $root), 'malformed renderer availability JSON'); + file_put_contents($autoload, ' getReadOnlyAvailabilityReport($availability, $project, $root), '64 KiB output limit'); + file_put_contents($autoload, ' getReadOnlyAvailabilityReport($availability, $project, $root), 'Project bootstrap failed'); + $delayedMarker = $root . '/timed-out-child-survived'; + file_put_contents($autoload, ' getReadOnlyAvailabilityReport($short, $project, $root), 'timed out'); + usleep(500000); + assertAvailability(! is_file($delayedMarker), 'A timed-out discovery child survived cleanup.'); + unlink($autoload); + expectAvailabilityFailure(static fn() => $availability->getReport($project), 'composer install'); + expectAvailabilityFailure(static fn() => $availability->getReport($root . '/missing'), 'valid project directory'); + assertAvailability(getcwd() === $workingDirectory && getenv('ICHILOTO_RENDERER') === 'parent-choice', + 'Discovery changed the parent working directory or renderer environment.'); +} finally { + putenv($rendererEnvironment === false ? 'ICHILOTO_RENDERER' : 'ICHILOTO_RENDERER=' . $rendererEnvironment); + removeAvailabilityDirectory($root); +} + +fwrite(STDOUT, "PASS: read-only project-authoritative renderer discovery, strict bounded reports, and explicit auto selection.\n"); diff --git a/tests/renderer-install.php b/tests/renderer-install.php index 838b3b9..862b304 100644 --- a/tests/renderer-install.php +++ b/tests/renderer-install.php @@ -259,7 +259,8 @@ function readManifest(string $boundary): array $originalPayload = file_get_contents($installedExecutable); foreach (['{', '{"version":2,"renderers":{"future":{}}}', '{"version":1}', '{"version":1,"renderers":{"testgpu":"invalid"}}', - '{"version":1,"renderers":{"testgpu":{"linux-x64":false}}}'] as $invalidManifest) { + '{"version":1,"renderers":{"testgpu":{"linux-x64":false}}}', + '{"version":1,"renderers":{"testgpu":{"linux-x64":"../escape"}}}'] as $invalidManifest) { file_put_contents($manifestFile, $invalidManifest); foreach ([true, false] as $dryRun) { $refused = false; @@ -267,6 +268,9 @@ function readManifest(string $boundary): array $installer->install($package['root'], $boundary, $dryRun); } catch (RuntimeException $error) { $refused = str_contains($error->getMessage(), 'existing renderer manifest'); + assertRendererInstall(str_contains($error->getMessage(), $manifestFile), 'manifest errors name the exact file.'); + assertRendererInstall(str_contains(strtolower($error->getMessage()), 'back'), 'manifest errors explain a safe backup/repair remedy.'); + assertRendererInstall(str_contains($error->getMessage(), 'Do not delete installed payloads or bypass verification'), 'recovery must preserve integrity checks.'); } assertRendererInstall($refused, 'an unmergeable manifest refuses real and dry-run installations.'); assertRendererInstall(file_get_contents($manifestFile) === $invalidManifest, 'a refused manifest stays byte-identical.'); @@ -280,6 +284,9 @@ function readManifest(string $boundary): array $installer->install($package['root'], $boundary); } catch (RuntimeException $error) { $refused = str_contains($error->getMessage(), 'cannot be read'); + assertRendererInstall(str_contains($error->getMessage(), $manifestFile) + && str_contains($error->getMessage(), 'regular file') + && str_contains($error->getMessage(), 'read permissions'), 'unreadable manifest errors name the path and access remedy.'); } assertRendererInstall($refused && is_dir($manifestFile), 'an unreadable non-file manifest is preserved and refused.'); rmdir($manifestFile); diff --git a/tests/renderer-preparation.php b/tests/renderer-preparation.php new file mode 100644 index 0000000..442df54 --- /dev/null +++ b/tests/renderer-preparation.php @@ -0,0 +1,272 @@ +getMessage(), $message), 'Unexpected failure: ' . $error->getMessage()); + return; + } + throw new RuntimeException('Expected update failure: ' . $message); +} + +$root = sys_get_temp_dir() . '/ichiloto-renderer-update-' . bin2hex(random_bytes(8)); +$engine = $root . '/engine'; +$source = $root . '/native'; +$project = $root . '/project with spaces'; +$installed = $engine . '/resources/renderers/installed'; +$platform = RendererPackageInstaller::hostPlatform(); +$output = new BufferedOutput(); +$originalPath = getenv('PATH'); +$buildCount = static fn (): int => is_file($source . '/builds') ? count(file($source . '/builds')) : 0; +try { + foreach ([$engine . '/resources/renderers', $engine . '/src/Core', $engine . '/.git', + $source . '/.git', $project . '/vendor', $project . '/assets'] as $directory) { mkdir($directory, 0755, true); } + copy(__DIR__ . '/fixtures/renderer-builder.php', $source . '/builder.php'); + file_put_contents($source . '/platform', $platform); + file_put_contents($source . '/input', 'revision-one'); + $declarationFile = $engine . '/resources/renderers/development.json'; + file_put_contents($declarationFile, json_encode(['version' => 1, + 'sources' => ['gpui' => ['directory' => '../../../native', 'builder' => 'builder.php']]], JSON_THROW_ON_ERROR)); + file_put_contents($engine . '/src/Core/Game.php', ' Ichiloto\Engine\Core\ProjectFormat::CURRENT, 'main' => 'main.php'])); + file_put_contents($project . '/main.php', 'check('/does/not/exist', 'terminal') === null && ! is_dir($installed), + 'Terminal check must be a no-op.'); + $first = $checker->check($project, 'gpui'); + assertUpdate($first !== null && ! $first->current && ! $first->skipped && ! is_dir($installed) + && $buildCount() === 0, 'Checking must not build or write.'); + + $choice = 'continue'; + $prompts = 0; + $play = new PlayCommand(rendererUpdateChecker: $checker, rendererUpdater: $updater, + terminalInteractivity: new TerminalInteractivity(static fn (): bool => true, static fn (): bool => true), + rendererUpdatePrompt: static function (string $label, array $options) use (&$choice, &$prompts): string { + $prompts++; + assertUpdate(array_keys($options) === ['update', 'continue', 'skip'], 'Unexpected update choices.'); + return $choice; + }); + $playTester = new CommandTester($play); + $launch = static function (bool $interactive = true) use ($playTester, $project): array { + @unlink($project . '/launched'); + $status = $playTester->execute(['--directory' => $project, '--renderer' => 'gpui', '--no-tmux' => true], + ['interactive' => $interactive]); + return [$status, $playTester->getDisplay(), is_file($project . '/launched') ? file_get_contents($project . '/launched') : null]; + }; + [$status, $display, $renderer] = $launch(false); + assertUpdate($status === Command::SUCCESS && $renderer === 'gpui' && $prompts === 0 + && $buildCount() === 0 && ! is_dir($installed) && str_contains($display, 'renderer:update'), + 'Noninteractive play must report and launch without prompt or build.'); + $launch(); + assertUpdate($prompts === 1 && $buildCount() === 0, 'Continue must not build or save a skip.'); + $choice = 'skip'; + $launch(); + assertUpdate($prompts === 2 && is_file($first->getSkipFile()) && $buildCount() === 0, + 'Skip must record offered fingerprint without building.'); + $launch(); + assertUpdate($prompts === 2, 'Skip must suppress repeat prompts for that fingerprint.'); + file_put_contents($source . '/input', 'revision-two'); + $launch(); + assertUpdate($prompts === 3, 'New fingerprint must be offered after skip.'); + $headless = new CommandTester(new PlayCommand(rendererUpdateChecker: $checker, rendererUpdater: $updater, + terminalInteractivity: new TerminalInteractivity(static fn (): bool => false, static fn (): bool => false), + rendererUpdatePrompt: static fn (): never => throw new RuntimeException('No TTY must not prompt.'))); + $status = $headless->execute(['--directory' => $project, '--renderer' => 'gpui', '--no-tmux' => true], + ['interactive' => true]); + assertUpdate($status === Command::SUCCESS && $buildCount() === 0, + 'Interactive input without a terminal must not prompt or build.'); + + $commandTester = new CommandTester(new RendererUpdateCommand($updater)); + $status = $commandTester->execute(['--directory' => $project], ['interactive' => false]); + $receipt = $first->getReceiptFile(); + $executable = $installed . '/gpui/' . $platform . '/renderer'; + assertUpdate($status === Command::SUCCESS && $buildCount() === 1 && is_executable($executable) + && is_file($receipt) && ! is_file($first->getSkipFile()), + 'Real renderer:update command must install and clear skip.'); + assertUpdate($checker->check($project, 'gpui')?->current === true, 'Verified install must be current.'); + $commandTester->execute(['--directory' => $project], ['interactive' => false]); + assertUpdate($buildCount() === 1, 'Unchanged explicit update must reuse verified install.'); + file_put_contents($project . '/assets/portrait.png', 'changed art'); + file_put_contents($project . '/changed.php', 'check($project, 'gpui')?->current === true, + 'Game assets and PHP must not invalidate renderer source.'); + + file_put_contents($source . '/input', 'revision-three'); + $choice = 'update'; + [$status, , $renderer] = $launch(); + assertUpdate($status === Command::SUCCESS && $renderer === 'gpui' && $buildCount() === 2 + && count(glob($installed . '/.backups/*')) === 1, 'Update-now must install and launch.'); + file_put_contents($executable, 'tampered'); + assertUpdate($checker->check($project, 'gpui')?->current === false, 'Receipt cannot mask tampered payload.'); + $commandTester->execute(['--directory' => $project], ['interactive' => false]); + assertUpdate($buildCount() === 3 && file_get_contents($executable) !== 'tampered', + 'Explicit update must repair tampered payload.'); + + foreach (['fail' => 'Deliberate build failure', 'changed' => 'source changed during', + 'wrong-renderer' => 'does not match', 'wrong-platform' => 'does not match', 'bad-hash' => 'SHA-256'] as $mode => $message) { + file_put_contents($source . '/input', 'failure-' . $mode); + file_put_contents($source . '/mode', $mode); + $before = [file_get_contents($executable), file_get_contents($installed . '/manifest.json'), file_get_contents($receipt)]; + expectUpdateFailure(fn () => $updater->update($project, 'gpui', $output), $message); + assertUpdate($before === [file_get_contents($executable), file_get_contents($installed . '/manifest.json'), file_get_contents($receipt)], + 'Failed update changed previous install: ' . $mode); + [$status, $display, $renderer] = $launch(); + assertUpdate($status === Command::SUCCESS && $renderer === 'gpui' && str_contains($display, 'Continuing game launch'), + 'Failed update must not stop actual launch: ' . $mode); + if ($mode === 'fail') { + assertUpdate($commandTester->execute(['--directory' => $project], ['interactive' => false]) === Command::FAILURE, + 'Explicit renderer:update must report build failure.'); + } + } + unlink($source . '/mode'); + file_put_contents($source . '/input', 'timeout-revision'); + $boundedChecker = new SourceRendererUpdateChecker(probeTimeoutSeconds: 0.3); + file_put_contents($source . '/mode', 'hung-description'); + $started = microtime(true); + expectUpdateFailure(fn () => $boundedChecker->check($project, 'gpui'), 'timed out'); + assertUpdate(microtime(true) - $started < 3, 'Read-only update check must be bounded.'); + $timeoutPlay = new CommandTester(new PlayCommand(rendererUpdateChecker: $boundedChecker, + terminalInteractivity: new TerminalInteractivity(static fn (): bool => false, static fn (): bool => false))); + $status = $timeoutPlay->execute(['--directory' => $project, '--renderer' => 'gpui', '--no-tmux' => true], + ['interactive' => false]); + assertUpdate($status === Command::SUCCESS && str_contains($timeoutPlay->getDisplay(), 'Continuing game launch'), + 'Timed-out check must not block real launch.'); + unlink($source . '/mode'); + $bounded = new SourceRendererUpdater($checker, buildTimeoutSeconds: 0.3, lockTimeoutSeconds: 0.3); + file_put_contents($source . '/mode', 'hung-build'); + $started = microtime(true); + expectUpdateFailure(fn () => $bounded->update($project, 'gpui', $output), 'timed out'); + assertUpdate(microtime(true) - $started < 3, 'Hung build must terminate promptly.'); + unlink($source . '/mode'); + $busy = fopen($installed . '/.source-update.lock', 'c'); + flock($busy, LOCK_EX); + try { + $started = microtime(true); + expectUpdateFailure(fn () => $bounded->update($project, 'gpui', $output), 'lock timed out'); + assertUpdate(microtime(true) - $started < 3, 'Busy lock must be bounded.'); + } finally { flock($busy, LOCK_UN); fclose($busy); } + $runner = new RendererPreparationProcess(); + expectUpdateFailure(fn () => $runner->run([PHP_BINARY, '-r', 'echo str_repeat("x", 70000);'], $project), '64 KiB'); + $pidFile = $root . '/hung.pid'; + $started = microtime(true); + expectUpdateFailure(fn () => $runner->run([PHP_BINARY, '-r', + 'file_put_contents($argv[1], (string)getmypid()); fclose(STDOUT); fclose(STDERR); sleep(10);', $pidFile], + $project, timeoutSeconds: 0.3), 'timed out'); + assertUpdate(microtime(true) - $started < 3, 'Closed tool pipes must not allow unbounded exit wait.'); + if (function_exists('posix_kill')) { + assertUpdate(! @posix_kill((int) file_get_contents($pidFile), 0), 'Timed-out tool must terminate.'); + } + + file_put_contents($source . '/input', 'concurrent-revision'); + file_put_contents($source . '/mode', 'slow'); + $beforeBuilds = $buildCount(); + $children = []; + for ($index = 0; $index < 2; $index++) { + $pipes = []; + $child = proc_open([PHP_BINARY, __DIR__ . '/fixtures/renderer-preparation-worker.php', $project], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + assertUpdate(is_resource($child), 'Could not start concurrent update fixture.'); + fclose($pipes[0]); + $children[] = [$child, $pipes]; + } + foreach ($children as [$child, $pipes]) { + stream_get_contents($pipes[1]); $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + assertUpdate(proc_close($child) === 0, 'Concurrent update failed: ' . $errors); + } + assertUpdate($buildCount() === $beforeBuilds + 1, 'Concurrent updates must recheck under lock.'); + unlink($source . '/mode'); + + file_put_contents($source . '/input', 'new-after-concurrency'); + file_put_contents($first->getSkipFile(), '{invalid JSON'); + $beforeState = file_get_contents($first->getSkipFile()); + $status = $checker->check($project, 'gpui'); + assertUpdate($status !== null && ! $status->current && ! $status->skipped + && file_get_contents($first->getSkipFile()) === $beforeState, + 'Malformed skip state must not hide an update or be rewritten by a check.'); + file_put_contents($receipt, '{invalid JSON'); + assertUpdate($checker->check($project, 'gpui')?->current === false + && file_get_contents($receipt) === '{invalid JSON', + 'Malformed receipt must be treated as stale without check-time writes.'); + + file_put_contents($source . '/platform', 'linux-x64'); + $linuxChecker = new SourceRendererUpdateChecker(platform: 'linux-x64'); + (new SourceRendererUpdater($linuxChecker))->update($project, 'gpui', $output); + assertUpdate(is_file($installed . '/gpui/linux-x64/renderer'), 'Linux identity must select Linux package.'); + file_put_contents($source . '/platform', $platform); + $beforeBuilds = $buildCount(); + rename($declarationFile, $declarationFile . '.saved'); + assertUpdate($checker->check($project, 'gpui') === null, 'No declaration means no update check.'); + rename($declarationFile . '.saved', $declarationFile); + rmdir($engine . '/.git'); + file_put_contents($declarationFile, 'invalid copied declaration'); + assertUpdate($checker->check($project, 'gpui') === null, 'Distribution must ignore copied declaration.'); + $vendored = $project . '/vendor/ichiloto/engine'; + mkdir($vendored . '/.git', 0755, true); + mkdir($vendored . '/resources/renderers', 0755, true); + mkdir($vendored . '/src/Core', 0755, true); + copy($engine . '/src/Core/Game.php', $vendored . '/src/Core/Game.php'); + copy($declarationFile, $vendored . '/resources/renderers/development.json'); + $autoload = file_get_contents($project . '/vendor/autoload.php'); + file_put_contents($project . '/vendor/autoload.php', 'check($project, 'gpui') === null, + 'Vendored Git Engine must not activate source update checks.'); + file_put_contents($project . '/vendor/autoload.php', $autoload); + mkdir($engine . '/.git'); + file_put_contents($declarationFile, json_encode(['version' => 1, + 'sources' => ['gpui' => ['directory' => '../../../missing-source', 'builder' => 'builder.php']]])); + [$status, $display, $renderer] = $launch(false); + assertUpdate($status === Command::SUCCESS && $renderer === 'gpui' && str_contains($display, 'Continuing game launch'), + 'Missing source check must warn yet launch.'); + assertUpdate($commandTester->execute(['--directory' => $project], ['interactive' => false]) === Command::FAILURE, + 'Explicit update must report a missing source.'); + $terminalTester = new CommandTester($play); + assertUpdate($terminalTester->execute(['--directory' => $project, '--renderer' => 'terminal', '--no-tmux' => true], + ['interactive' => false]) === Command::SUCCESS + && ! str_contains($terminalTester->getDisplay(), 'Renderer update check'), + 'Terminal play must not probe the missing graphical source.'); + + mkdir($root . '/bin'); + file_put_contents($root . '/bin/tmux', "#!/bin/sh\nexit 0\n"); + chmod($root . '/bin/tmux', 0755); + putenv('PATH=' . $root . '/bin:' . $originalPath); + $method = new ReflectionMethod(PlayCommand::class, 'launchInTmux'); + $tmuxOutput = new BufferedOutput(); + $status = $method->invoke($play, $project, $project . '/main.php', $project . '/logs/error.log', 'gpui', + new ArrayInput([]), $tmuxOutput); + assertUpdate($status === 0 && $buildCount() === $beforeBuilds + && ! str_contains($tmuxOutput->fetch(), 'Renderer update'), 'Tmux reattach must not check or build.'); + + echo "renderer-preparation: read-only play checks and explicit updates passed (fake builder, no native launch).\n"; +} finally { + putenv($originalPath === false ? 'PATH' : 'PATH=' . $originalPath); + if (is_dir($root)) { + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { $file->isDir() && ! $file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } + rmdir($root); + } +} diff --git a/tests/renderer-selection.php b/tests/renderer-selection.php index c6c434a..5ccd6e8 100644 --- a/tests/renderer-selection.php +++ b/tests/renderer-selection.php @@ -8,6 +8,7 @@ use Ichiloto\Console\Renderer\RendererSelector; use Ichiloto\Console\Support\GameLaunchCommandBuilder; use Ichiloto\Console\Support\TerminalInteractivity; +use Ichiloto\Console\Support\SourceRendererUpdateChecker; use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\ApplicationTester; @@ -48,6 +49,7 @@ function rendererTestCommand( static fn (): bool => $outputIsTty, ), launchCommandBuilder: new GameLaunchCommandBuilder(), + rendererUpdateChecker: new SourceRendererUpdateChecker(locateEngine: static fn (string $project): string => $project), ); } @@ -417,6 +419,7 @@ static function (string $label, array $options) use (&$promptLabel, &$promptOpti mkdir($fakeTmuxDirectory, 0777, true); $projectConfig = json_encode([ + 'format' => Ichiloto\Engine\Core\ProjectFormat::CURRENT, 'main' => 'app/game.php', 'debug' => ['enabled' => false, 'show' => false], ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL; @@ -468,6 +471,7 @@ static function (string $label, array $options) use (&$promptLabel, &$promptOpti file_put_contents($appDirectory . '/pty-game.php', $ptyFixtureGameSource); $spacedProjectConfig = json_encode([ + 'format' => Ichiloto\Engine\Core\ProjectFormat::CURRENT, 'main' => 'game files/game runner.php', 'debug' => ['enabled' => false, 'show' => false], ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL; @@ -682,6 +686,7 @@ static function (string $label, array $options) use (&$promptLabel, &$promptOpti ); $observedOutputProjectConfig = json_encode([ + 'format' => Ichiloto\Engine\Core\ProjectFormat::CURRENT, 'main' => 'app/pty-game.php', 'debug' => ['enabled' => false, 'show' => false], ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL; @@ -745,7 +750,7 @@ static function (string $label, array $options) use (&$promptLabel, &$promptOpti 'The failing game did not append stderr to the error log.', ); - file_put_contents($projectDirectory . '/ichiloto.json', json_encode(['main' => 'app/missing.php'], JSON_THROW_ON_ERROR)); + file_put_contents($projectDirectory . '/ichiloto.json', json_encode(['format' => Ichiloto\Engine\Core\ProjectFormat::CURRENT, 'main' => 'app/missing.php'], JSON_THROW_ON_ERROR)); $result = runRendererPlayCommand( rendererTestCommand($neverPrompt), 'relative-project', diff --git a/tests/terminal-session.php b/tests/terminal-session.php new file mode 100644 index 0000000..271ae78 --- /dev/null +++ b/tests/terminal-session.php @@ -0,0 +1,326 @@ + array_slice($argv, 4), 'directory' => getcwd(), 'environment' => getenv('FIXTURE_VALUE')], JSON_THROW_ON_ERROR)); + echo "synthetic terminal stdout\n"; + fwrite(STDERR, "synthetic terminal stderr\n"); + exit(0); + } + if ($mode === 'diagnostic') { + file_put_contents(getenv(TerminalSessionLauncher::DIAGNOSTIC_LOG_ENVIRONMENT), "synthetic crash diagnostic\n", FILE_APPEND); + exit(23); + } + file_put_contents($argv[3], (string) getmypid()); + if ($mode === 'worker-interrupt') { + posix_kill(posix_getppid(), SIGINT); + } + if ($mode === 'stubborn') { + pcntl_async_signals(true); + pcntl_signal(SIGTERM, SIG_IGN); + } + if ($mode === 'descendants') { + $process = TerminalSessionProcess::start([PHP_BINARY, '-d', 'memory_limit=512M', __FILE__, '--game', 'stubborn', $argv[4]], [0 => STDIN, 1 => STDOUT, 2 => STDERR]); + while (true) { + usleep(20000); + } + } + if ($mode === 'residual') { + $process = TerminalSessionProcess::start([PHP_BINARY, '-d', 'memory_limit=512M', __FILE__, '--game', 'stubborn', $argv[4]], [0 => STDIN, 1 => STDOUT, 2 => STDERR]); + usleep(500000); + exit(0); + } + if ($mode === 'brief') { + usleep(3500000); + exit(0); + } + while (true) { + usleep(20000); + } +} + +/** @var list $processes */ +$processes = []; +function assertTerminal(bool $condition, string $message): void +{ + if (! $condition) { + throw new RuntimeException($message); + } +} + +function waitForTerminal(callable $condition, string $message, float $timeout = 5.0): void +{ + $deadline = microtime(true) + $timeout; + while (! $condition()) { + if (microtime(true) >= $deadline) { + throw new RuntimeException($message); + } + usleep(20000); + } +} + +/** @return list */ +function buildFixtureGame(string $mode, string ...$arguments): array +{ + return [PHP_BINARY, '-d', 'memory_limit=512M', __FILE__, '--game', $mode, ...$arguments]; +} + +/** Returns an injected Windows-shaped opener but runs only a headless PHP worker. */ +function createHeadlessWindow(string $root, ?callable $beforeStart = null, float $delay = 0.0, bool $delayExit = false): TerminalSessionWindow +{ + return new TerminalSessionWindow(platform: 'Windows', commandRunner: static function (array $arguments, array $descriptors, array $options) use ($root, $beforeStart, $delay, $delayExit) { + assertTerminal(($options['create_new_console'] ?? false) === true, 'Windows requests its own console'); + assertTerminal($descriptors === [], 'Windows does not inherit GUI log handles'); + $beforeStart?->__invoke($arguments); + $worker = [PHP_BINARY, '-d', 'memory_limit=512M', __FILE__, $delayExit ? '--worker-delay-exit' : '--worker', ...array_slice($arguments, -3)]; + if ($delay > 0) { + $worker = [PHP_BINARY, '-d', 'memory_limit=512M', '-r', 'usleep((int) $argv[1]); $p = proc_open(array_slice($argv, 2), [STDIN, STDOUT, STDERR], $pipes); exit(proc_close($p));', (string) (int) ($delay * 1000000), ...$worker]; + } + $process = TerminalSessionProcess::start($worker, [0 => ['file', '/dev/null', 'r'], 1 => ['file', $root . '/worker.log', 'a'], 2 => ['file', $root . '/worker.log', 'a']]); + if ($delayExit && is_resource($process)) { + file_put_contents($root . '/display.pid', (string) proc_get_status($process)['pid']); + } + + return $process; + }, findExecutable: static fn (string $name): ?string => $name); +} + +if (($argv[1] ?? '') === '--owner') { + $root = $argv[2]; + $mode = $argv[3]; + $launcher = new TerminalSessionLauncher(createHeadlessWindow($root), scratchRoot: $root, readyTimeout: 3.0); + exit($launcher->launch($root, buildFixtureGame($mode, $root . '/game.pid', $root . '/descendant.pid'), []) ?? 125); +} +if (($argv[1] ?? '') === '--editor') { + $root = $argv[2]; + $process = TerminalSessionProcess::start([PHP_BINARY, '-d', 'memory_limit=512M', __FILE__, '--owner', $root, 'stubborn'], [0 => ['file', '/dev/null', 'r'], 1 => ['file', $root . '/owner.log', 'a'], 2 => ['file', $root . '/owner.log', 'a']]); + file_put_contents($root . '/play.pid', (string) proc_get_status($process)['pid']); + while (true) { + usleep(20000); + } +} + +$base = sys_get_temp_dir(); +if (! is_dir($base)) { + throw new RuntimeException('The configured test scratch root is unavailable.'); +} +$root = $base . '/terminal-session-' . bin2hex(random_bytes(12)); +mkdir($root, 0700); +$project = $root . (PHP_OS_FAMILY === 'Windows' ? "/project space ' dollar $;" : "/project space ' quote \" dollar $;"); +mkdir($project, 0700); +$output = ''; +$writer = static function (string $text) use (&$output): void { $output .= $text; }; +$originalDisplay = getenv('DISPLAY'); + +try { + $nonce = str_repeat('a', 64); + $workerArguments = ["/php path/'quoted\"", '-d', 'memory_limit=512M', "/worker path/'\";$()\nfile.php", '127.0.0.1:1234', $nonce, '123.456']; + $commands = []; + $runner = static function (array $arguments, array $descriptors, array $options) use (&$commands) { + $commands[] = [$arguments, $descriptors, $options]; + return false; + }; + $finder = static fn (string $name): ?string => '/available/' . basename($name); + $mac = new TerminalSessionWindow(platform: 'Darwin', commandRunner: $runner, findExecutable: $finder); + $script = $mac->buildOpenCommand($workerArguments, $nonce)['arguments'][2]; + assertTerminal(str_contains($script, 'set ownedTab to do script ') && ! str_contains($script, ' in window'), 'macOS creates a dedicated new window'); + assertTerminal(str_contains($script, "'\\\\''") && str_contains($script, '\\"') && str_contains($script, '\\n'), 'macOS uses shell and AppleScript quoting independently'); + $mac->closeOwnedWindow($nonce); + $closeScript = $commands[0][0][2]; + assertTerminal(str_contains($closeScript, 'count of tabs of ownedWindow') && str_contains($closeScript, $nonce) && str_contains($closeScript, 'busy of tab 1'), 'macOS closes only an idle, nonce-owned, single-tab window'); + assertTerminal(! str_contains($closeScript, 'quit') && ! str_contains($closeScript, 'close every'), 'macOS never quits the shared Terminal app'); + putenv('DISPLAY=:headless-fixture'); + $linux = new TerminalSessionWindow(platform: 'Linux', commandRunner: $runner, findExecutable: $finder); + $linuxCommand = $linux->buildOpenCommand($workerArguments, $nonce)['arguments']; + assertTerminal(array_slice($linuxCommand, 4) === $workerArguments && $linuxCommand[3] === '-e', 'xterm receives a literal executable vector'); + $canRunLifecycle = PHP_OS_FAMILY !== 'Windows' && function_exists('pcntl_signal') && function_exists('posix_kill'); + assertTerminal($canRunLifecycle ? $linux->describeUnavailable() === null : str_contains($linux->describeUnavailable() ?? '', 'extensions'), 'Linux adapter checks its runtime requirements'); + putenv('DISPLAY'); + assertTerminal(str_contains($linux->describeUnavailable() ?? '', $canRunLifecycle ? 'display' : 'extensions'), 'Linux without prerequisites refuses explicitly'); + $missing = new TerminalSessionWindow(platform: 'Linux', commandRunner: $runner, findExecutable: static fn (): ?string => null); + assertTerminal(str_contains($missing->describeUnavailable() ?? '', $canRunLifecycle ? 'xterm' : 'extensions'), 'Linux without a supported terminal diagnoses availability'); + assertTerminal(str_contains((new TerminalSessionWindow(platform: 'Other'))->describeUnavailable() ?? '', 'unsupported'), 'unsupported platform refuses'); + $windows = new TerminalSessionWindow(platform: 'Windows', commandRunner: $runner, findExecutable: $finder); + $winCommand = $windows->buildOpenCommand($workerArguments, $nonce); + assertTerminal($winCommand['arguments'] === $workerArguments && $winCommand['options']['create_new_console'] === true, 'Windows bypasses shells and owns its console'); + $windowsParent = ['ProcessId' => 123, 'CreationDate' => 'original-parent']; + $windowsOwner = new TerminalSessionOwner(platform: 'Windows', queryWindows: static function () use (&$windowsParent): ?array { return $windowsParent; }); + assertTerminal($windowsOwner->isAlive(), 'Windows owner starts with a stable process identity'); + $windowsParent['CreationDate'] = 'reused-pid'; + assertTerminal(! $windowsOwner->isAlive(), 'Windows does not mistake a reused PID for the editor'); + $windowsParent = null; + assertTerminal(! $windowsOwner->isAlive(), 'Windows parent disappearance cancels'); + + if (! $canRunLifecycle) { + echo "terminal-session: structural checks ok; POSIX lifecycle fixtures skipped on this host. No native window opened.\n"; + + return; + } + + $launcher = new TerminalSessionLauncher(createHeadlessWindow($root), $root, outputWriter: $writer); + $capture = $root . '/capture.json'; + $values = ['space value', "single'quote", 'double"quote', '$(touch NOT_ALLOWED)', '; exit 77', "line\nbreak", 'percent%PATH%', 'back\\slash', '']; + $code = $launcher->launch($project, buildFixtureGame('capture', $capture, ...$values), ['FIXTURE_VALUE' => "literal ' \" ; $()"]); + assertTerminal($code === 0, 'normal completion returns the child exit code'); + $captured = json_decode(file_get_contents($capture), true, flags: JSON_THROW_ON_ERROR); + assertTerminal($captured['arguments'] === $values && $captured['directory'] === $project, 'argument/path injection characters arrive literally'); + assertTerminal($captured['environment'] === "literal ' \" ; $()", 'environment data is not shell evaluated'); + assertTerminal(str_contains(file_get_contents($root . '/worker.log'), 'synthetic terminal stdout') && str_contains(file_get_contents($root . '/worker.log'), 'synthetic terminal stderr'), 'game inherits the worker terminal streams'); + assertTerminal(glob($root . '/ichiloto-terminal-*') === [], 'completion removes only owned scratch'); + $macLiteralScript = $mac->buildOpenCommand(buildFixtureGame('capture', $capture, ...$values), $nonce)['arguments'][2]; + preg_match('/set ownedTab to do script (".*")/', $macLiteralScript, $macLiteral); + $shellCommand = json_decode($macLiteral[1], true, flags: JSON_THROW_ON_ERROR); + $shellProcess = TerminalSessionProcess::start(['/bin/sh', '-c', $shellCommand], [0 => ['file', '/dev/null', 'r'], 1 => ['file', $root . '/worker.log', 'a'], 2 => ['file', $root . '/worker.log', 'a']], $project); + assertTerminal(is_resource($shellProcess) && proc_close($shellProcess) === 0, 'macOS generated shell command runs headlessly'); + assertTerminal(json_decode(file_get_contents($capture), true, flags: JSON_THROW_ON_ERROR)['arguments'] === $values, 'actual macOS shell quoting preserves every literal argument'); + + $rogue = new TerminalSessionLauncher(createHeadlessWindow($root, beforeStart: static function (array $arguments): void { + $stream = stream_socket_client('tcp://' . $arguments[count($arguments) - 3]); + fwrite($stream, json_encode(['type' => 'hello', 'nonce' => str_repeat('c', 64)]) . "\n"); + fclose($stream); + }), $root, outputWriter: $writer); + assertTerminal($rogue->launch($project, buildFixtureGame('capture', $capture), []) === 0, 'a foreign nonce is rejected without authorizing or displacing the real worker'); + assertTerminal($launcher->launch($project, ['/not/an/executable'], []) === null && str_contains($launcher->getFailureReason() ?? '', 'executable'), 'worker start refusal reaches Play with its diagnostic'); + + $diagnostic = $root . '/diagnostic.log'; + file_put_contents($diagnostic, 'historical log is not replayed' . "\n"); + $code = $launcher->launch($project, buildFixtureGame('diagnostic'), [TerminalSessionLauncher::DIAGNOSTIC_LOG_ENVIRONMENT => $diagnostic]); + assertTerminal($code === 23, 'nonzero child status reaches Play without waiting on a crash display'); + assertTerminal(str_contains($output, 'synthetic crash diagnostic') && ! str_contains($output, 'historical log'), 'new diagnostic log data reaches the parent output'); + assertTerminal(str_contains($output, 'status 23'), 'failure summary reaches the editor log'); + // Owned mode removes crash-window holding, while preserving the game exit/log. + $display = new TerminalSessionLauncher(createHeadlessWindow($root, delayExit: true), $root, outputWriter: $writer); + $started = microtime(true); + assertTerminal($display->launch($project, buildFixtureGame('diagnostic'), [TerminalSessionLauncher::DIAGNOSTIC_LOG_ENVIRONMENT => $diagnostic]) === 23 && microtime(true) - $started < 3.5, 'nonzero completion stops a lingering window process within its bounded cleanup'); + assertTerminal(glob($root . '/ichiloto-terminal-*') === [], 'nonzero completion holds no owned scratch'); + $displayPid = (int) file_get_contents($root . '/display.pid'); + assertTerminal(! @posix_kill($displayPid, 0), 'owned nonzero completion leaves no crash display process'); + + $failure = new TerminalSessionLauncher($windows, $root, 0.2, $writer); + assertTerminal($failure->launch($project, buildFixtureGame('capture', $capture), []) === null && str_contains($failure->getFailureReason() ?? '', 'open'), 'window opening failure refuses, without a GUI-log fallback'); + assertTerminal($failure->launch('/missing/directory', [], []) === null, 'invalid source directory refuses before starting'); + assertTerminal($failure->launch($project, ['bad' . "\0" . 'argument'], []) === null, 'NUL arguments refuse'); + assertTerminal($failure->launch($project, buildFixtureGame('capture', $capture), ['BAD=KEY' => 'value']) === null, 'invalid environment refuses'); + + $neverReady = new TerminalSessionWindow(platform: 'Windows', findExecutable: $finder, commandRunner: static fn () => TerminalSessionProcess::start([PHP_BINARY, '-d', 'memory_limit=512M', '-r', 'usleep(1000000);'], [])); + $timeout = new TerminalSessionLauncher($neverReady, $root, 0.12, $writer); + $started = microtime(true); + assertTerminal($timeout->launch($project, buildFixtureGame('capture', $capture), []) === null, 'ready timeout refuses a window that never rendezvouses'); + assertTerminal(microtime(true) - $started < 3.5 && str_contains($timeout->getFailureReason() ?? '', 'timeout'), 'pre-ready waiting is bounded'); + + $lateCapture = $root . '/late.json'; + $late = new TerminalSessionLauncher(createHeadlessWindow($root, delay: 0.35), $root, 0.08, $writer); + assertTerminal($late->launch($project, buildFixtureGame('capture', $lateCapture), []) === null, 'late startup expires'); + usleep(450000); + assertTerminal(! file_exists($lateCapture) && glob($root . '/ichiloto-terminal-*') === [], 'late worker never starts a game or leaves scratch'); + + $pidFile = $root . '/live.pid'; + $started = microtime(true); + $live = new TerminalSessionLauncher(createHeadlessWindow($root), $root, 3.0, $writer); + assertTerminal($live->launch($project, buildFixtureGame('brief', $pidFile), []) === 0 && microtime(true) - $started >= 3.5, 'ready timeout does not limit a live game and Play remains synchronous'); + unlink($pidFile); + $cancel = new TerminalSessionLauncher(createHeadlessWindow($root), $root, outputWriter: $writer, cancellationRequested: static fn (): bool => is_file($pidFile)); + assertTerminal($cancel->launch($project, buildFixtureGame('stubborn', $pidFile), []) === 143, 'explicit cancellation returns a meaningful status'); + $gamePid = (int) file_get_contents($pidFile); + waitForTerminal(static fn (): bool => ! @posix_kill($gamePid, 0), 'cancellation left the stubborn game alive'); + assertTerminal(glob($root . '/ichiloto-terminal-*') === [], 'cancellation cleans authenticated scratch'); + assertTerminal($launcher->launch($project, buildFixtureGame('worker-interrupt', $pidFile), []) === 130, 'interrupting the terminal worker reports interruption rather than an invented crash status'); + $residual = $root . '/residual.pid'; + assertTerminal($launcher->launch($project, buildFixtureGame('residual', $root . '/live.pid', $residual), []) === 0, 'normal completion also reaps residual consumers'); + $residualPid = (int) file_get_contents($residual); + waitForTerminal(static fn (): bool => ! @posix_kill($residualPid, 0), 'normal completion left an overlay consumer alive'); + + // A foreign or substituted scratch directory is never recursively removed. + $scratch = new TerminalSessionScratch($root, $nonce); + $scratch->create(); + file_put_contents($scratch->path . '/foreign', 'preserve'); + $scratch->remove(); + assertTerminal(is_file($scratch->path . '/foreign') && is_file($scratch->path . '/owner'), 'foreign files prevent cleanup ownership loss'); + unlink($scratch->path . '/foreign'); + $scratch->remove(); + assertTerminal(! file_exists($scratch->path), 'owned-only scratch cleans'); + mkdir($scratch->path, 0700); + file_put_contents($scratch->path . '/owner', str_repeat('b', 64)); + $scratch->remove(); + assertTerminal(is_file($scratch->path . '/owner'), 'wrong owner is preserved'); + unlink($scratch->path . '/owner'); + rmdir($scratch->path); + symlink($project, $scratch->path); + $scratch->remove(); + assertTerminal(is_link($scratch->path) && is_dir($project), 'scratch cleanup never follows a substituted directory symlink'); + unlink($scratch->path); + + foreach ([SIGINT, SIGTERM, SIGKILL] as $signal) { + @unlink($root . '/game.pid'); + @unlink($root . '/descendant.pid'); + $resource = TerminalSessionProcess::start([PHP_BINARY, '-d', 'memory_limit=512M', __FILE__, '--owner', $root, 'descendants'], [0 => ['file', '/dev/null', 'r'], 1 => ['file', $root . '/owner.log', 'a'], 2 => ['file', $root . '/owner.log', 'a']]); + assertTerminal(is_resource($resource), 'synthetic owner starts'); + $owner = new TerminalSessionProcess($resource); + $processes[] = $owner; + waitForTerminal(static fn (): bool => is_file($root . '/descendant.pid'), 'game descendant did not become ready'); + // Allow the worker to record its game-local descendants before forced death. + usleep(300000); + $gamePid = (int) file_get_contents($root . '/game.pid'); + $descendantPid = (int) file_get_contents($root . '/descendant.pid'); + posix_kill($owner->getProcessId(), $signal); + waitForTerminal(static fn (): bool => ! $owner->isRunning(), 'Play did not exit after the signal'); + $owner->close(); + waitForTerminal(static fn (): bool => ! @posix_kill($gamePid, 0), 'parent death left the owned game alive'); + waitForTerminal(static fn (): bool => ! @posix_kill($descendantPid, 0), 'parent death left an owned descendant alive'); + waitForTerminal(static fn (): bool => glob($root . '/ichiloto-terminal-*') === [], 'parent death left owned scratch'); + } + + @unlink($root . '/game.pid'); + $resource = TerminalSessionProcess::start([PHP_BINARY, '-d', 'memory_limit=512M', __FILE__, '--editor', $root], [0 => ['file', '/dev/null', 'r'], 1 => ['file', $root . '/owner.log', 'a'], 2 => ['file', $root . '/owner.log', 'a']]); + assertTerminal(is_resource($resource), 'synthetic editor starts'); + $editor = new TerminalSessionProcess($resource); + $processes[] = $editor; + waitForTerminal(static fn (): bool => is_file($root . '/game.pid'), 'editor-owned game did not start'); + $gamePid = (int) file_get_contents($root . '/game.pid'); + $playPid = (int) file_get_contents($root . '/play.pid'); + posix_kill($editor->getProcessId(), SIGKILL); + waitForTerminal(static fn (): bool => ! $editor->isRunning(), 'editor did not die'); + $editor->close(); + waitForTerminal(static fn (): bool => ! @posix_kill($gamePid, 0), 'editor death left the terminal game alive while Play was reparented'); + waitForTerminal(static fn (): bool => ! @posix_kill($playPid, 0), 'editor death left Play alive'); + assertTerminal(glob($root . '/ichiloto-terminal-*') === [], 'editor death cleans owned scratch'); + + echo "terminal-session: ok; headless lifecycle and command construction only; no native window opened.\n"; +} finally { + foreach ($processes as $process) { + $process->stop(); + } + $originalDisplay === false ? putenv('DISPLAY') : putenv('DISPLAY=' . $originalDisplay); + foreach (glob($root . '/ichiloto-terminal-*') ?: [] as $path) { + $token = substr(basename($path), strlen('ichiloto-terminal-')); + new TerminalSessionScratch($root, $token)->remove(); + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($root); +} diff --git a/tests/validate-interactive-migration.php b/tests/validate-interactive-migration.php new file mode 100644 index 0000000..4ef5cef --- /dev/null +++ b/tests/validate-interactive-migration.php @@ -0,0 +1,117 @@ +execute(['--directory' => $projectRoot], ['interactive' => true]); + return ['code' => $code, 'output' => $tester->getDisplay(), 'prompts' => $prompts]; +} + +function removeInteractiveProject(string $root): void +{ + if (! is_dir($root)) { return; } + $entries = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($entries as $entry) { + $entry->isDir() && ! $entry->isLink() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($root); +} + +try { + new NewProjectScaffolder()->scaffold([ + 'displayName' => 'Interactive Validation', + 'directoryName' => 'interactive-validation', + 'targetDirectory' => $projectRoot, + 'heroName' => 'Aria Vale', + 'heroId' => 'AriaVale', + 'battleEngine' => 'traditional', + 'titleArt' => "INTERACTIVE VALIDATION\n", + ]); + $actorPath = $projectRoot . '/assets/Data/Actors/AriaVale.php'; + $systemPath = $projectRoot . '/assets/Data/system.php'; + + $clean = runInteractiveValidation($projectRoot, false); + if ($clean['code'] !== Command::SUCCESS || $clean['prompts'] !== 0 + || str_contains($clean['output'], 'Actor identity migration would update')) { + throw new RuntimeException('Clean interactive validation unnecessarily offered actor migration: ' . $clean['output']); + } + + $presentationDirectory = $projectRoot . '/assets/Data/Presentation'; + mkdir($presentationDirectory, 0700, true); + $presentationPath = $presentationDirectory . '/menus.php'; + file_put_contents($presentationPath, " 'Aria Vale',\n", '', $actorSource, $removedIds); + if ($removedIds !== 1) { + throw new RuntimeException('Could not create an actor missing its stable id.'); + } + file_put_contents($actorPath, $legacyActorSource); + $declined = runInteractiveValidation($projectRoot, false); + if ($declined['code'] !== Command::FAILURE || $declined['prompts'] !== 1 + || file_get_contents($actorPath) !== $legacyActorSource + || ! str_contains($declined['output'], 'AriaVale.php')) { + throw new RuntimeException('Declining actor migration changed source or skipped confirmation: ' . $declined['output']); + } + $accepted = runInteractiveValidation($projectRoot, true); + $actor = require $actorPath; + if ($accepted['code'] !== Command::SUCCESS || $accepted['prompts'] !== 1 + || ($actor['data']['id'] ?? null) !== 'Aria Vale') { + throw new RuntimeException('Confirmed interactive actor migration failed: ' . $accepted['output']); + } + + $systemSource = (string) file_get_contents($systemPath); + $staleSystemSource = str_replace("'Aria Vale',", "'AriaVale',", $systemSource, $replacedReferences); + if ($replacedReferences !== 1) { + throw new RuntimeException('Could not create a stale starting-party reference.'); + } + file_put_contents($systemPath, $staleSystemSource); + $referenceRepair = runInteractiveValidation($projectRoot, true); + $system = require $systemPath; + if ($referenceRepair['code'] !== Command::SUCCESS || $referenceRepair['prompts'] !== 1 + || ($system['startingParty'] ?? null) !== ['Aria Vale'] + || ! str_contains($referenceRepair['output'], 'system.php')) { + throw new RuntimeException('Confirmed interactive stale-reference repair failed: ' . $referenceRepair['output']); + } +} catch (Throwable $failure) { + $failureMessage = $failure->getMessage(); +} finally { + removeInteractiveProject($projectRoot); +} + +if (isset($failureMessage)) { + fwrite(STDERR, 'FAIL: ' . $failureMessage . "\n"); + exit(1); +} + +fwrite(STDOUT, "PASS: interactive validation plans only actor migration candidates and confirms every source change.\n"); diff --git a/tests/validate-working-directory.php b/tests/validate-working-directory.php index fa15bf8..f99a644 100644 --- a/tests/validate-working-directory.php +++ b/tests/validate-working-directory.php @@ -158,7 +158,9 @@ function fail(string $message): never try { $baseline = validationReport($consoleBin, $projectRoot, '.'); - if ($baseline['exitCode'] !== 0 || ! str_contains($baseline['output'], 'Last Legend looks good.')) { + if ($baseline['exitCode'] !== 0 + || (! str_contains($baseline['output'], 'Last Legend looks good.') + && ! str_contains($baseline['output'], '0 errors,'))) { fail('The Last Legend project-root validation baseline did not pass: ' . $baseline['output']); } } catch (TestFailure $failure) {