From 2d91f7599dda9297fd1cf4b49bd1a3f2fc697a0f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 22 Sep 2026 22:08:11 +0200 Subject: [PATCH 01/32] fix(renderer): explain safe manifest recovery --- src/Support/RendererPackageInstaller.php | 35 +++++++++++++++++++----- tests/renderer-install.php | 9 +++++- 2 files changed, 36 insertions(+), 8 deletions(-) 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/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); From ee9ca53d068e7d08856f9fa6d65eac6b49703477 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 22:44:59 +0200 Subject: [PATCH 02/32] fix(console): preserve actor identities from generation through startup Declare stable actor ids, reference them in starter parties, and offer source-preserving legacy migration during validation. Ignore private player settings in new projects and cover silent real-game startup. --- composer.json | 2 + src/Commands/GenerateActorCommand.php | 1 + src/Commands/ValidateCommand.php | 38 ++++- src/Support/NewProjectScaffolder.php | 8 +- tests/generate-actor-identity.php | 51 +++++++ tests/new-project-save-compatibility.php | 75 +++++++++ tests/project-startup-identity.php | 185 +++++++++++++++++++++++ tests/validate-working-directory.php | 4 +- 8 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 tests/generate-actor-identity.php create mode 100644 tests/project-startup-identity.php diff --git a/composer.json b/composer.json index 43005d7..68a1827 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,8 @@ "@php tests/renderer-install.php", "@php tests/generate-map.php", "@php tests/new-project-save-compatibility.php", + "@php tests/generate-actor-identity.php", + "@php tests/project-startup-identity.php", "@php tests/legacy-project-upgrade.php", "@php tests/validate-working-directory.php", "@php tests/battle-report.php" 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/ValidateCommand.php b/src/Commands/ValidateCommand.php index b275cfb..a68f71e 100644 --- a/src/Commands/ValidateCommand.php +++ b/src/Commands/ValidateCommand.php @@ -2,6 +2,7 @@ namespace Ichiloto\Console\Commands; +use Ichiloto\Editor\Actors\ActorIdentityMigration; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\Issue; use Ichiloto\Editor\Validation\ProjectValidator; @@ -12,6 +13,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Throwable; +use function Laravel\Prompts\confirm; #[AsCommand( name: 'validate', @@ -23,12 +25,13 @@ 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, 'Add stable ids from current names to legacy actors without an id.'); } 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 . ''); @@ -37,9 +40,9 @@ public function execute(InputInterface $input, OutputInterface $output): int } $this->bootstrapDependencies($workingDirectory); - 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 +52,35 @@ 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))); + } + + $shouldMigrate = (bool) $input->getOption('migrate-actor-ids') + || ($input->isInteractive() && confirm( + 'Add each legacy actor\'s current name as its permanent id?', + false, + )); + + if ($shouldMigrate) { + try { + $changedPaths = ActorIdentityMigration::migrateProject($workingDirectory); + $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); validation after migration:', count($changedPaths))); + $this->report($output, $workspace->projectName, $issues); + } else { + $output->writeln('Run validate --migrate-actor-ids to apply this one-time migration non-interactively.'); + } + } + $errors = $this->countOf($issues, Severity::ERROR); $warnings = $this->countOf($issues, Severity::WARNING); diff --git a/src/Support/NewProjectScaffolder.php b/src/Support/NewProjectScaffolder.php index 066e1cf..302be01 100644 --- a/src/Support/NewProjectScaffolder.php +++ b/src/Support/NewProjectScaffolder.php @@ -93,7 +93,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'], ), ); @@ -504,11 +504,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 +517,7 @@ private function renderSystemData(string $title, string $heroId, string $battleE 'amount' => 0, ], 'startingParty' => [ - $heroId, + $heroIdentity, ], 'startingInventory' => [], 'startingPositions' => [ @@ -591,6 +592,7 @@ private function renderActorData(string $heroName): string 'class' => Character::class, 'data' => [ 'name' => HERO_NAME, + 'id' => HERO_NAME, 'description' => '', 'level' => 1, 'currentExp' => 0, 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/new-project-save-compatibility.php b/tests/new-project-save-compatibility.php index e38bc6f..7b551ed 100644 --- a/tests/new-project-save-compatibility.php +++ b/tests/new-project-save-compatibility.php @@ -46,6 +46,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,10 +79,14 @@ 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.'); @@ -92,6 +113,60 @@ 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']); + } + + $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/project-startup-identity.php b/tests/project-startup-identity.php new file mode 100644 index 0000000..9225552 --- /dev/null +++ b/tests/project-startup-identity.php @@ -0,0 +1,185 @@ +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(); + } +} + +/** @return array{code: int, output: string} */ +function runStartupChild(string $projectRoot): array +{ + $process = proc_open( + [PHP_BINARY, __FILE__, '--child', $projectRoot], + [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) + 30; + do { + $status = proc_get_status($process); + if (! $status['running']) { break; } + usleep(50000); + } while (microtime(true) < $deadline); + if ($status['running']) { + proc_terminate($process); + proc_close($process); + throw new RuntimeException('Bounded Game startup exceeded 30 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]; +} + +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', "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'; +$epicSource = dirname(__DIR__) . '/../examples/epic-quest'; + +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']); + } + + mkdir($epicRoot, 0700, true); + copySmallProjectTree($epicSource . '/assets', $epicRoot . '/assets'); + copy($epicSource . '/input.php', $epicRoot . '/input.php'); + copy($epicSource . '/ichiloto.json', $epicRoot . '/ichiloto.json'); + 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']); + } +} finally { + removeStartupFixture($temporaryRoot); +} + +fwrite(STDOUT, "PASS: fresh and EpicQuest projects reach real field startup with effective audio muted.\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) { From b71ee5c288b0e7524063149ca766f314ddfb1124 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 23 Sep 2026 22:54:55 +0200 Subject: [PATCH 03/32] test(console): make startup fixtures portable across checkouts --- tests/project-startup-identity.php | 103 +++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 27 deletions(-) diff --git a/tests/project-startup-identity.php b/tests/project-startup-identity.php index 9225552..f3d0643 100644 --- a/tests/project-startup-identity.php +++ b/tests/project-startup-identity.php @@ -11,11 +11,10 @@ use Ichiloto\Engine\Util\Config\PlayerSettings; use Ichiloto\Engine\Util\Config\ProjectConfig; -if (($argv[1] ?? null) === '--child') { - require dirname(__DIR__, 2) . '/engine/vendor/autoload.php'; -} else { - require dirname(__DIR__) . '/vendor/autoload.php'; -} +require dirname(__DIR__) . '/vendor/autoload.php'; + +const STARTUP_TIMEOUT_SECONDS = 30; +const STARTUP_POLL_MICROSECONDS = 50_000; /** Real title, loader, field and Engine loop; stops immediately after field startup. */ final class StartupIdentityProbe extends Game @@ -57,16 +56,16 @@ function runStartupChild(string $projectRoot): array throw new RuntimeException('Could not start bounded Game process.'); } fclose($pipes[0]); - $deadline = microtime(true) + 30; + $deadline = microtime(true) + STARTUP_TIMEOUT_SECONDS; do { $status = proc_get_status($process); if (! $status['running']) { break; } - usleep(50000); + usleep(STARTUP_POLL_MICROSECONDS); } while (microtime(true) < $deadline); if ($status['running']) { proc_terminate($process); proc_close($process); - throw new RuntimeException('Bounded Game startup exceeded 30 seconds.'); + 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); } @@ -118,27 +117,64 @@ function makeSilentConfig(string $projectRoot, array $config): void file_put_contents($projectRoot . '/config.php', "getFileName(); + if (! str_starts_with($resolved, $localSource . DIRECTORY_SEPARATOR)) { + throw new RuntimeException("Console did not resolve {$class} from the local Engine source."); + } + } +} + +function assertMutedConfig(ProjectConfig $effective): void +{ + foreach (['audio.music', 'audio.sfx', 'audio.voice'] as $path) { + if (boolval($effective->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 { - ConfigStore::put(PlayerSettings::class, new PlayerSettings($projectRoot)); - AudioMutePreflight::assertMuted(new ProjectConfig()); + 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 { - ConfigStore::remove(PlayerSettings::class); + if (class_exists(PlayerSettings::class)) { ConfigStore::remove(PlayerSettings::class); } chdir($previous); } } if (($argv[1] ?? null) === '--child') { $projectRoot = $argv[2] ?? ''; + assertUpdatedLocalEngineIsSelected(); + assertSilentProject($projectRoot); chdir($projectRoot); - ConfigStore::put(PlayerSettings::class, new PlayerSettings($projectRoot)); - AudioMutePreflight::assertMuted(new ProjectConfig()); $game = new StartupIdentityProbe('Identity startup check'); - AudioMutePreflight::assertMuted(ConfigStore::get(ProjectConfig::class)); + $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); @@ -147,7 +183,10 @@ function assertSilentProject(string $projectRoot): void $temporaryRoot = sys_get_temp_dir() . '/ichiloto-startup-identity-' . bin2hex(random_bytes(8)); $freshRoot = $temporaryRoot . '/fresh'; $epicRoot = $temporaryRoot . '/epic-quest'; -$epicSource = dirname(__DIR__) . '/../examples/epic-quest'; +$epicFixture = getenv('ICHILOTO_EPIC_QUEST_SRC'); +$epicSource = is_string($epicFixture) && $epicFixture !== '' ? realpath($epicFixture) : false; + +assertUpdatedLocalEngineIsSelected(); try { new NewProjectScaffolder()->scaffold([ @@ -167,19 +206,29 @@ function assertSilentProject(string $projectRoot): void throw new RuntimeException('Fresh project did not reach field startup: ' . $fresh['output']); } - mkdir($epicRoot, 0700, true); - copySmallProjectTree($epicSource . '/assets', $epicRoot . '/assets'); - copy($epicSource . '/input.php', $epicRoot . '/input.php'); - copy($epicSource . '/ichiloto.json', $epicRoot . '/ichiloto.json'); - 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: fresh project reaches real field startup with effective audio muted.\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'); + 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); } - -fwrite(STDOUT, "PASS: fresh and EpicQuest projects reach real field startup with effective audio muted.\n"); From 265fa941c506eea6e84c7fd202c54b2ffb319354 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 24 Sep 2026 00:15:06 +0200 Subject: [PATCH 04/32] fix(console): repair released actor references during validation Exercise the generated Console 0.5.0 project through muted New Game startup before and after explicit, source-preserving migration. Preserve the released fixture bytes and note the required future Editor dependency. --- README.md | 6 + src/Commands/ValidateCommand.php | 47 +++++- .../.data/saves/.gitkeep | 0 .../released-console-0.5.0-project/.gitignore | 10 ++ .../assets/Data/Actors/AriaVale.php | 45 ++++++ .../assets/Data/Entities/player.php | 10 ++ .../assets/Data/abilities.php | 3 + .../assets/Data/animations.php | 3 + .../assets/Data/classes.php | 3 + .../assets/Data/enemies.php | 3 + .../assets/Data/items.php | 3 + .../assets/Data/magic.php | 3 + .../assets/Data/save-compatibility.php | 8 + .../assets/Data/skills.php | 3 + .../assets/Data/system.php | 36 +++++ .../assets/Data/troops.php | 3 + .../Graphics/Animations/battle-transition.txt | 3 + .../assets/Graphics/System/game-over.txt | 3 + .../assets/Graphics/System/title.txt | 1 + .../campfire-clearing.data.php | 13 ++ .../campfire-clearing.event.php | 22 +++ .../campfire-clearing.map.php | 22 +++ .../assets/Maps/collisions.php | 21 +++ .../composer.json | 12 ++ .../released-console-0.5.0-project/config.php | 103 +++++++++++++ .../ichiloto.json | 22 +++ .../released-console-0.5.0-project/input.php | 66 ++++++++ .../logs/.gitkeep | 0 .../released-0-5-project.php | 17 ++ tests/project-startup-identity.php | 145 ++++++++++++++++++ 30 files changed, 628 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/released-console-0.5.0-project/.data/saves/.gitkeep create mode 100644 tests/fixtures/released-console-0.5.0-project/.gitignore create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/Actors/AriaVale.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/Entities/player.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/abilities.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/animations.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/classes.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/enemies.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/items.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/magic.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/save-compatibility.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/skills.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/system.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Data/troops.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Graphics/Animations/battle-transition.txt create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Graphics/System/game-over.txt create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Graphics/System/title.txt create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Maps/campfire-clearing/campfire-clearing.data.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Maps/campfire-clearing/campfire-clearing.event.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Maps/campfire-clearing/campfire-clearing.map.php create mode 100644 tests/fixtures/released-console-0.5.0-project/assets/Maps/collisions.php create mode 100644 tests/fixtures/released-console-0.5.0-project/composer.json create mode 100644 tests/fixtures/released-console-0.5.0-project/config.php create mode 100644 tests/fixtures/released-console-0.5.0-project/ichiloto.json create mode 100644 tests/fixtures/released-console-0.5.0-project/input.php create mode 100644 tests/fixtures/released-console-0.5.0-project/logs/.gitkeep create mode 100644 tests/fixtures/released-console-0.5.0-project/released-0-5-project.php diff --git a/README.md b/README.md index 2fa51df..061353c 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,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 diff --git a/src/Commands/ValidateCommand.php b/src/Commands/ValidateCommand.php index a68f71e..bb9efd2 100644 --- a/src/Commands/ValidateCommand.php +++ b/src/Commands/ValidateCommand.php @@ -26,7 +26,7 @@ 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('migrate-actor-ids', null, InputOption::VALUE_NONE, 'Add stable ids from current names to legacy actors without an id.'); + ->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 @@ -38,6 +38,7 @@ public function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } + $workingDirectory = realpath($workingDirectory) ?: $workingDirectory; $this->bootstrapDependencies($workingDirectory); try { @@ -57,16 +58,34 @@ public function execute(InputInterface $input, OutputInterface $output): int foreach ($pendingActors as $actor) { $output->writeln(sprintf(' %s (%s)', $actor->getName(), basename($actor->path))); } + } + + $migrationRequested = (bool) $input->getOption('migrate-actor-ids'); + if ($migrationRequested || $input->isInteractive()) { + 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 = (bool) $input->getOption('migrate-actor-ids') + $shouldMigrate = $plannedPaths !== [] && ($migrationRequested || ($input->isInteractive() && confirm( - 'Add each legacy actor\'s current name as its permanent id?', + 'Freeze missing actor ids and update their legacy references?', false, - )); + ))); if ($shouldMigrate) { try { - $changedPaths = ActorIdentityMigration::migrateProject($workingDirectory); + $changedPaths = $plan->apply(); $workspace = ProjectWorkspace::fromProject($workingDirectory); $issues = new ProjectValidator()->validate($workspace); } catch (Throwable $throwable) { @@ -74,13 +93,25 @@ public function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $output->writeln(sprintf('Added stable ids to %d actor(s); validation after migration:', count($changedPaths))); + $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); - } else { - $output->writeln('Run validate --migrate-actor-ids to apply this one-time migration non-interactively.'); + } 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 getValidationResult(InputInterface $input, array $issues): int + { $errors = $this->countOf($issues, Severity::ERROR); $warnings = $this->countOf($issues, Severity::WARNING); 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/project-startup-identity.php b/tests/project-startup-identity.php index f3d0643..b1b7165 100644 --- a/tests/project-startup-identity.php +++ b/tests/project-startup-identity.php @@ -76,6 +76,39 @@ function runStartupChild(string $projectRoot): array 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'), + ]; +} + function copySmallProjectTree(string $source, string $destination): void { if (! is_dir($destination) && ! mkdir($destination, 0700, true)) { @@ -183,6 +216,9 @@ function assertSilentProject(string $projectRoot): void $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; @@ -208,6 +244,115 @@ function assertSilentProject(string $projectRoot): void 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. + $releasedSource = __DIR__ . '/fixtures/released-console-0.5.0-project'; + copySmallProjectTree($releasedSource, $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); + $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']); + } + $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: already identified actors have stale released party references repaired.\n"); + + copySmallProjectTree($releasedSource, $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 { From a166d8225728fd1e1da024012651b4333b8f1b5e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 24 Sep 2026 02:17:24 +0200 Subject: [PATCH 05/32] fix(console): stop planning migration for unrelated validation Remove automatic actor-migration planning from ordinary interactive validation when no missing actor ID or unresolved actor reference exists. Keep explicit migration and confirmed repairs for legacy references; exercise the real title alert on a released 0.5.0 project. --- composer.json | 1 + src/Commands/ValidateCommand.php | 16 +++- tests/project-startup-identity.php | 82 +++++++++++++++- tests/validate-interactive-migration.php | 117 +++++++++++++++++++++++ 4 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 tests/validate-interactive-migration.php diff --git a/composer.json b/composer.json index 68a1827..8299928 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "@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/validate-working-directory.php", "@php tests/battle-report.php" diff --git a/src/Commands/ValidateCommand.php b/src/Commands/ValidateCommand.php index bb9efd2..bf8c020 100644 --- a/src/Commands/ValidateCommand.php +++ b/src/Commands/ValidateCommand.php @@ -4,6 +4,7 @@ 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; @@ -61,7 +62,8 @@ public function execute(InputInterface $input, OutputInterface $output): int } $migrationRequested = (bool) $input->getOption('migrate-actor-ids'); - if ($migrationRequested || $input->isInteractive()) { + $hasMigrationCandidate = $pendingActors !== [] || $this->hasUnresolvedActorReferences($issues); + if ($migrationRequested || ($input->isInteractive() && $hasMigrationCandidate)) { try { $plan = ActorIdentityMigration::planProject($workingDirectory); } catch (Throwable $throwable) { @@ -109,6 +111,18 @@ public function execute(InputInterface $input, OutputInterface $output): int 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 { diff --git a/tests/project-startup-identity.php b/tests/project-startup-identity.php index b1b7165..50ee90f 100644 --- a/tests/project-startup-identity.php +++ b/tests/project-startup-identity.php @@ -5,23 +5,61 @@ use Ichiloto\Console\Support\NewProjectScaffolder; use Ichiloto\Engine\Audio\AudioMutePreflight; use Ichiloto\Engine\Core\Game; +use Ichiloto\Engine\Core\Menu\Commands\MenuCommandExecutionContext; +use Ichiloto\Engine\Core\Menu\Commands\NewGameCommand; +use Ichiloto\Engine\Core\Menu\TitleMenu\TitleMenu; +use Ichiloto\Engine\IO\Console\Console; +use Ichiloto\Engine\IO\Enumerations\KeyCode; +use Ichiloto\Engine\IO\InputManager; +use Ichiloto\Engine\IO\InputSources\InputSourceInterface; use Ichiloto\Engine\Scenes\Game\GameLoader; use Ichiloto\Engine\Scenes\Game\GameScene; +use Ichiloto\Engine\Scenes\Title\TitleScene; use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Config\PlayerSettings; use Ichiloto\Engine\Util\Config\ProjectConfig; +use Symfony\Component\Console\Output\BufferedOutput; require dirname(__DIR__) . '/vendor/autoload.php'; const STARTUP_TIMEOUT_SECONDS = 30; const STARTUP_POLL_MICROSECONDS = 50_000; +/** Capture the rendered alert while it is still visible, then dismiss it. */ +final class CapturedAlertInputSource implements InputSourceInterface +{ + private ?string $renderedAlert = null; + private bool $dismissed = false; + + public function poll(): ?KeyCode + { + if ($this->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) { @@ -34,13 +72,45 @@ protected function start(): void 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): array +function runStartupChild(string $projectRoot, string $probeMode = 'field'): array { $process = proc_open( - [PHP_BINARY, __FILE__, '--child', $projectRoot], + [PHP_BINARY, __FILE__, '--child', $projectRoot, $probeMode], [0 => ['pipe', 'r'], 1 => ['file', $projectRoot . '/startup.stdout', 'w'], 2 => ['file', $projectRoot . '/startup.stderr', 'w']], $pipes, $projectRoot, @@ -203,6 +273,7 @@ function assertSilentProject(string $projectRoot): void 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.'); @@ -316,6 +387,11 @@ function assertSilentProject(string $projectRoot): void || ! 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']); @@ -332,7 +408,7 @@ function assertSilentProject(string $projectRoot): void || ! 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: already identified actors have stale released party references repaired.\n"); + fwrite(STDOUT, "PASS: stale released party references show a recoverable title alert, then migrate cleanly.\n"); copySmallProjectTree($releasedSource, $unsupportedRoot); $unsupportedActorPath = $unsupportedRoot . '/assets/Data/Actors/AriaVale.php'; 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"); From 36d05561f40bd7ea1403263844b3ad066df611f4 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 24 Sep 2026 03:36:28 +0200 Subject: [PATCH 06/32] fix(console): remove automatic renderer rebuilds from play Remove play refusal to launch when renderer preparation fails. Check declared source fingerprints without building, offer update, continue, or skip, and add renderer:update for requested builds. Reuse verified package installation and preserve the previous payload on failed builds. --- README.md | 33 ++- bin/ichiloto | 2 + composer.json | 1 + src/Commands/PlayCommand.php | 63 +++- src/Commands/RendererUpdateCommand.php | 54 ++++ src/Support/RendererPreparationProcess.php | 96 +++++++ src/Support/SourceRendererUpdate.php | 45 +++ src/Support/SourceRendererUpdateChecker.php | 155 ++++++++++ src/Support/SourceRendererUpdater.php | 160 +++++++++++ tests/fixtures/renderer-builder.php | 35 +++ .../fixtures/renderer-preparation-worker.php | 8 + tests/renderer-preparation.php | 272 ++++++++++++++++++ tests/renderer-selection.php | 2 + 13 files changed, 921 insertions(+), 5 deletions(-) create mode 100644 src/Commands/RendererUpdateCommand.php create mode 100644 src/Support/RendererPreparationProcess.php create mode 100644 src/Support/SourceRendererUpdate.php create mode 100644 src/Support/SourceRendererUpdateChecker.php create mode 100644 src/Support/SourceRendererUpdater.php create mode 100644 tests/fixtures/renderer-builder.php create mode 100644 tests/fixtures/renderer-preparation-worker.php create mode 100644 tests/renderer-preparation.php diff --git a/README.md b/README.md index 061353c..b769e95 100644 --- a/README.md +++ b/README.md @@ -101,15 +101,40 @@ 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. 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: diff --git a/bin/ichiloto b/bin/ichiloto index f5900f8..6b86e84 100755 --- a/bin/ichiloto +++ b/bin/ichiloto @@ -20,6 +20,7 @@ use Ichiloto\Console\Commands\GenerateMapCommand; use Ichiloto\Console\Commands\NewCommand; use Ichiloto\Console\Commands\PlayCommand; use Ichiloto\Console\Commands\RendererInstallCommand; +use Ichiloto\Console\Commands\RendererUpdateCommand; use Ichiloto\Console\Commands\UpgradeCommand; use Ichiloto\Console\Commands\ValidateCommand; use Symfony\Component\Console\Application; @@ -40,6 +41,7 @@ function bootstrap(): int new GenerateActorCommand(), new GenerateFigletCommand(), new RendererInstallCommand(), + new RendererUpdateCommand(), new UpgradeCommand(), new ValidateCommand() ]); diff --git a/composer.json b/composer.json index 8299928..e74d551 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "@php tests/local-sibling-autoload.php", "@php tests/renderer-selection.php", "@php tests/renderer-install.php", + "@php tests/renderer-preparation.php", "@php tests/generate-map.php", "@php tests/new-project-save-compatibility.php", "@php tests/generate-actor-identity.php", diff --git a/src/Commands/PlayCommand.php b/src/Commands/PlayCommand.php index 1b9b915..2acd775 100644 --- a/src/Commands/PlayCommand.php +++ b/src/Commands/PlayCommand.php @@ -10,13 +10,20 @@ use Ichiloto\Console\Support\GameLaunchCommandBuilder; use Ichiloto\Console\Support\GameProcessLauncher; use Ichiloto\Console\Support\TerminalInteractivity; +use Ichiloto\Console\Support\SourceRendererUpdateChecker; +use Ichiloto\Console\Support\SourceRendererUpdater; use Ichiloto\Console\Util\Path; +use Closure; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Formatter\OutputFormatter; +use Throwable; + +use function Laravel\Prompts\select; #[AsCommand( name: 'play', @@ -34,18 +41,32 @@ class PlayCommand extends Command private readonly GameProcessLauncher $gameProcessLauncher; + private readonly SourceRendererUpdateChecker $rendererUpdateChecker; + + private readonly SourceRendererUpdater $rendererUpdater; + + private readonly Closure $rendererUpdatePrompt; + 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, ) { $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->rendererUpdateChecker = $rendererUpdateChecker ?? new SourceRendererUpdateChecker(); + $this->rendererUpdater = $rendererUpdater ?? new SourceRendererUpdater($this->rendererUpdateChecker); + $this->rendererUpdatePrompt = $rendererUpdatePrompt === null + ? static fn (string $label, array $options): int|string => select(label: $label, options: $options) + : Closure::fromCallable($rendererUpdatePrompt); parent::__construct(); } @@ -129,9 +150,11 @@ public function execute(InputInterface $input, OutputInterface $output): int $errorLogFile = $this->prepareErrorLogFile($workingDirectory); 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); } + $this->offerRendererUpdate($workingDirectory, $renderer->id, $input, $output); + $resultCode = $this->gameProcessLauncher->launch( workingDirectory: $workingDirectory, mainFile: $mainFile, @@ -189,6 +212,8 @@ private function launchInTmux( string $mainFile, string $errorLogFile, string $rendererId, + InputInterface $input, + OutputInterface $output, ): int { $sessionName = 'ichiloto-play-' . preg_replace('/[^A-Za-z0-9_-]+/', '-', basename($workingDirectory)); @@ -196,6 +221,7 @@ private function launchInTmux( $launchCommand = $this->launchCommandBuilder->buildCrashPreservingCommand($gameCommand, 'Ichiloto game'); if (! $this->tmuxSessionExists($sessionName)) { + $this->offerRendererUpdate($workingDirectory, $rendererId, $input, $output); passthru($this->launchCommandBuilder->buildTmuxNewSessionCommand( sessionName: $sessionName, workingDirectory: $workingDirectory, @@ -213,6 +239,41 @@ private function launchInTmux( return $exitCode; } + private function offerRendererUpdate( + string $workingDirectory, + string $rendererId, + InputInterface $input, + OutputInterface $output, + ): void + { + try { + $update = $this->rendererUpdateChecker->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->rendererUpdatePrompt)('Renderer update available', [ + 'update' => 'Update now', + 'continue' => 'Continue this launch', + 'skip' => 'Skip this version', + ]); + if ($choice === 'update') { + $installed = $this->rendererUpdater->update($workingDirectory, $rendererId, $output); + $output->writeln($installed ? 'Renderer updated.' : 'Renderer is already current.'); + } elseif ($choice === 'skip') { + $this->rendererUpdater->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.'); + } + } + /** * Applies tmux options that help preserve crash output. * 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/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/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/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/renderer-preparation.php b/tests/renderer-preparation.php new file mode 100644 index 0000000..131606d --- /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', ' '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..dc7078b 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), ); } From fc3f1da20b8529ffea8a6f836696fc68cffaf2f9 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:13:03 +0200 Subject: [PATCH 07/32] refactor(console)!: run ichiloto upgrade as a numbered project format chain ichiloto.json records "format"; a project without one is format 0. Each format is one step class implementing ProjectUpgradeStepInterface (target version, a plan with one human line per pending change, and apply producing a report). The chain runs every step from the project's format to its target in order and records the new format after each step. A later format adds a step and never edits an earlier one. The existing save metadata upgrade becomes step 1, SaveMetadataStep, with unchanged behaviour and the --id option. LegacyProjectUpgrader is removed; its logic moved into SaveMetadataStep. The command needs no arguments. It lists each pending step's changes and asks to continue; --dry-run changes nothing; in a Git working tree it refuses to run over uncommitted changes unless --allow-dirty is given; it prints the follow-up items and writes them to ichiloto-upgrade-report.md. An up-to-date project reports that nothing is needed. BREAKING CHANGE: without a terminal, ichiloto upgrade now changes nothing unless --yes is given; it previously wrote the metadata immediately. Its old output lines are replaced by the plan and the report. --- src/Commands/UpgradeCommand.php | 145 +++++++++++++--- src/Support/LegacyProjectUpgrader.php | 182 -------------------- src/Upgrade/GitWorkingTree.php | 58 +++++++ src/Upgrade/ProjectConfigFile.php | 163 ++++++++++++++++++ src/Upgrade/ProjectFileWriter.php | 41 +++++ src/Upgrade/ProjectUpgradeChain.php | 99 +++++++++++ src/Upgrade/ProjectUpgradeContext.php | 52 ++++++ src/Upgrade/ProjectUpgradeFollowUp.php | 21 +++ src/Upgrade/ProjectUpgradePlan.php | 29 ++++ src/Upgrade/ProjectUpgradeReport.php | 43 +++++ src/Upgrade/ProjectUpgradeStepInterface.php | 26 +++ src/Upgrade/Steps/SaveMetadataStep.php | 139 +++++++++++++++ src/Upgrade/UpgradeReportDocument.php | 90 ++++++++++ tests/legacy-project-upgrade.php | 49 +++++- 14 files changed, 919 insertions(+), 218 deletions(-) delete mode 100644 src/Support/LegacyProjectUpgrader.php create mode 100644 src/Upgrade/GitWorkingTree.php create mode 100644 src/Upgrade/ProjectConfigFile.php create mode 100644 src/Upgrade/ProjectFileWriter.php create mode 100644 src/Upgrade/ProjectUpgradeChain.php create mode 100644 src/Upgrade/ProjectUpgradeContext.php create mode 100644 src/Upgrade/ProjectUpgradeFollowUp.php create mode 100644 src/Upgrade/ProjectUpgradePlan.php create mode 100644 src/Upgrade/ProjectUpgradeReport.php create mode 100644 src/Upgrade/ProjectUpgradeStepInterface.php create mode 100644 src/Upgrade/Steps/SaveMetadataStep.php create mode 100644 src/Upgrade/UpgradeReportDocument.php 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/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/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/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"); From 18beccfe4719fcf25b756f2578d7e1ecba32ac5f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:13:27 +0200 Subject: [PATCH 08/32] feat(console): add the two-column cell step to the format chain Format 2 makes a map cell two terminal columns wide. The step regroups every map layer and event layer by rewriting only the nowdoc body, never evaluating it: an odd-width row gains a trailing space and a two-column glyph starting halfway through a cell gets a space before it, then the result must read back through MapLayer::parseGrid. Field x coordinates are halved at their integer literal tokens (NPCs and wander areas, triggers, event spawn points and scripts, common events, cinematic cast, waypoints and finalizers, the new game start), classified from the engine's command schema, subject kinds, movement route shapes and the fields the loaders read; horizontal move route steps are halved and listed. Retired tiles2d crops are removed and TwoColumnCellsMigration is appended to the save compatibility chain in the manifest's own source. The report lists event cells with two markers and layers that no longer line up, then compares reachability instead of listing every cell that became solid: walkable ground is flood-filled from each map's entry points over the old per-column and the new per-cell collision, and event areas, NPCs, transfer triggers and arrivals that are no longer reached are named, with each cut-off region, a representative cell and the cell that most likely closed it. Anything it cannot identify is reported rather than guessed. --- composer.json | 1 + src/Upgrade/ProjectUpgradeChain.php | 5 +- src/Upgrade/Steps/TwoColumnCellsStep.php | 660 ++++++++++++++++++ .../FieldCoordinateConverter.php | 373 ++++++++++ .../TwoColumnCells/GridSourceConverter.php | 134 ++++ .../TwoColumnCells/MapCollisionComparison.php | 137 ++++ .../MapReachabilityComparison.php | 233 +++++++ .../TwoColumnCells/MapRowConverter.php | 110 +++ src/Upgrade/TwoColumnCells/PhpLiteral.php | 168 +++++ .../TwoColumnCells/PhpLiteralScanner.php | 230 ++++++ src/Upgrade/TwoColumnCells/ProjectMap.php | 92 +++ .../TwoColumnCells/SaveMigrationAppender.php | 149 ++++ .../Cinematics/arrival/arrival.data.php | 20 + .../Cinematics/arrival/arrival.script.php | 10 + .../assets/Data/save-compatibility.php | 20 + .../expected/assets/Data/system.php | 19 + .../expected/assets/Events/meet-elder.php | 17 + .../expected/assets/Maps/cave/cave.data.php | 7 + .../expected/assets/Maps/cave/cave.event.php | 9 + .../expected/assets/Maps/cave/cave.map.php | 9 + .../expected/assets/Maps/collisions.php | 12 + .../expected/assets/Maps/grove/grove.data.php | 7 + .../assets/Maps/grove/grove.event.php | 8 + .../expected/assets/Maps/grove/grove.map.php | 8 + .../Maps/village/layers/01.terrain.map.php | 11 + .../Maps/village/layers/02.props.deco.php | 12 + .../Maps/village/layers/03.walls.map.php | 11 + .../assets/Maps/village/village.data.php | 84 +++ .../assets/Maps/village/village.event.php | 11 + .../expected/ichiloto-upgrade-report.md | 69 ++ .../two-column-cells/expected/ichiloto.json | 8 + .../Cinematics/arrival/arrival.data.php | 20 + .../Cinematics/arrival/arrival.script.php | 10 + .../assets/Data/save-compatibility.php | 12 + .../project/assets/Data/system.php | 19 + .../project/assets/Events/meet-elder.php | 17 + .../project/assets/Maps/cave/cave.data.php | 7 + .../project/assets/Maps/cave/cave.event.php | 9 + .../project/assets/Maps/cave/cave.map.php | 9 + .../project/assets/Maps/collisions.php | 12 + .../project/assets/Maps/grove/grove.data.php | 7 + .../project/assets/Maps/grove/grove.event.php | 8 + .../project/assets/Maps/grove/grove.map.php | 8 + .../Maps/village/layers/01.terrain.map.php | 11 + .../Maps/village/layers/02.props.deco.php | 12 + .../Maps/village/layers/03.walls.map.php | 11 + .../assets/Maps/village/village.data.php | 90 +++ .../assets/Maps/village/village.event.php | 11 + .../two-column-cells/project/ichiloto.json | 8 + tests/legacy-project-upgrade.php | 23 +- tests/two-column-cells-upgrade.php | 263 +++++++ 51 files changed, 3203 insertions(+), 8 deletions(-) create mode 100644 src/Upgrade/Steps/TwoColumnCellsStep.php create mode 100644 src/Upgrade/TwoColumnCells/FieldCoordinateConverter.php create mode 100644 src/Upgrade/TwoColumnCells/GridSourceConverter.php create mode 100644 src/Upgrade/TwoColumnCells/MapCollisionComparison.php create mode 100644 src/Upgrade/TwoColumnCells/MapReachabilityComparison.php create mode 100644 src/Upgrade/TwoColumnCells/MapRowConverter.php create mode 100644 src/Upgrade/TwoColumnCells/PhpLiteral.php create mode 100644 src/Upgrade/TwoColumnCells/PhpLiteralScanner.php create mode 100644 src/Upgrade/TwoColumnCells/ProjectMap.php create mode 100644 src/Upgrade/TwoColumnCells/SaveMigrationAppender.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.data.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.script.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Data/save-compatibility.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Data/system.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Events/meet-elder.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.data.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.event.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.map.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/collisions.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.data.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.event.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.map.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/01.terrain.map.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/02.props.deco.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/03.walls.map.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/village/village.data.php create mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/village/village.event.php create mode 100644 tests/fixtures/two-column-cells/expected/ichiloto-upgrade-report.md create mode 100644 tests/fixtures/two-column-cells/expected/ichiloto.json create mode 100644 tests/fixtures/two-column-cells/project/assets/Cutscenes/Cinematics/arrival/arrival.data.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Cutscenes/Cinematics/arrival/arrival.script.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Data/save-compatibility.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Data/system.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Events/meet-elder.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.data.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.event.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.map.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/collisions.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.data.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.event.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.map.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/village/layers/01.terrain.map.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/village/layers/02.props.deco.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/village/layers/03.walls.map.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/village/village.data.php create mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/village/village.event.php create mode 100644 tests/fixtures/two-column-cells/project/ichiloto.json create mode 100644 tests/two-column-cells-upgrade.php diff --git a/composer.json b/composer.json index e74d551..3f2ebc8 100644 --- a/composer.json +++ b/composer.json @@ -52,6 +52,7 @@ "@php tests/project-startup-identity.php", "@php tests/validate-interactive-migration.php", "@php tests/legacy-project-upgrade.php", + "@php tests/two-column-cells-upgrade.php", "@php tests/validate-working-directory.php", "@php tests/battle-report.php" ] diff --git a/src/Upgrade/ProjectUpgradeChain.php b/src/Upgrade/ProjectUpgradeChain.php index b2bab29..6513be5 100644 --- a/src/Upgrade/ProjectUpgradeChain.php +++ b/src/Upgrade/ProjectUpgradeChain.php @@ -5,6 +5,8 @@ namespace Ichiloto\Console\Upgrade; use Ichiloto\Console\Upgrade\Steps\SaveMetadataStep; +use Ichiloto\Console\Upgrade\Steps\TwoColumnCellsStep; +use Ichiloto\Engine\Core\ProjectFormat; use InvalidArgumentException; /** @@ -47,7 +49,8 @@ public static function createDefault(): self { return new self([ new SaveMetadataStep(), - ], SaveMetadataStep::VERSION); + new TwoColumnCellsStep(), + ], ProjectFormat::CURRENT); } public function getRecordedVersion(ProjectUpgradeContext $context): int diff --git a/src/Upgrade/Steps/TwoColumnCellsStep.php b/src/Upgrade/Steps/TwoColumnCellsStep.php new file mode 100644 index 0000000..c23cb51 --- /dev/null +++ b/src/Upgrade/Steps/TwoColumnCellsStep.php @@ -0,0 +1,660 @@ +root); + $dictionary = $this->loadCollisionDictionary($context, $followUps); + $gridSummary = ['files' => 0, 'changed' => 0, 'padded' => 0, 'moved' => 0]; + $states = []; + + foreach ($maps as $map) { + $states[$map->id] = $this->convertMap($context, $map, $dictionary, $writes, $followUps, $gridSummary); + } + + $comparisons = array_map(static fn(?array $state): ?MapCollisionComparison => $state['comparison'] ?? null, $states); + $coordinateSummary = ['halved' => 0, 'files' => 0, 'steps' => 0, 'crops' => 0]; + $npcs = $positions = $areas = []; + + foreach ($this->findCoordinateFiles($context, $maps) as $path => [$kind, $mapId]) { + $converted = $this->convertCoordinates($context, $path, $kind, $mapId, $comparisons, $writes, $followUps, $coordinateSummary, $npcs); + array_push($positions, ...$converted['positions']); + array_push($areas, ...$converted['areas']); + } + + $this->compareReachability($states, $positions, $areas, $followUps); + + foreach ($npcs as $cell => $labels) { + // NPCs that already shared a column were already in one cell. + if (count($labels) > 1) { + $labels = array_merge(...array_values($labels)); + $followUps[] = new ProjectUpgradeFollowUp(self::SHARED_CELLS, sprintf( + '%s: %s now share one cell; the game finds only the first NPC in a cell, so move one unless their conditions never overlap.', + $cell, + implode(' and ', $labels), + )); + } + } + + $changes = [ + sprintf( + 'Regroup %d %s into two-column cells: %d of %d grid files change (%d %s padded to an even width, %d two-column %s moved right).', + count($maps), count($maps) === 1 ? 'map' : 'maps', + $gridSummary['changed'], $gridSummary['files'], + $gridSummary['padded'], $gridSummary['padded'] === 1 ? 'row' : 'rows', + $gridSummary['moved'], $gridSummary['moved'] === 1 ? 'glyph' : 'glyphs', + ), + sprintf( + 'Halve %d field x %s in %d %s.', + $coordinateSummary['halved'], $coordinateSummary['halved'] === 1 ? 'coordinate' : 'coordinates', + $coordinateSummary['files'], $coordinateSummary['files'] === 1 ? 'file' : 'files', + ), + ]; + + if ($coordinateSummary['steps'] > 0) { + $changes[] = sprintf('Halve %d horizontal move route %s (each is listed for review).', $coordinateSummary['steps'], $coordinateSummary['steps'] === 1 ? 'step' : 'steps'); + } + + if ($coordinateSummary['crops'] > 0) { + $changes[] = sprintf('Remove retired tiles2d crops from %d %s.', $coordinateSummary['crops'], $coordinateSummary['crops'] === 1 ? 'map' : 'maps'); + } + + $changes[] = $this->planSaveMigration($context, $writes, $followUps); + $blocking = count(array_filter($followUps, static fn(ProjectUpgradeFollowUp $item): bool => $item->blocking)); + $changes[] = sprintf( + 'Report %d %s that block loading and %d for review or hand conversion.', + $blocking, $blocking === 1 ? 'item' : 'items', count($followUps) - $blocking, + ); + $followUps[] = new ProjectUpgradeFollowUp(self::NEXT, 'Re-proportion furniture and rooms for square cells where they look stretched; that is the author\'s work after the conversion.'); + $followUps[] = new ProjectUpgradeFollowUp(self::NEXT, 'Run `ichiloto validate`, then play through the converted maps and cinematics.'); + + return new ProjectUpgradePlan(self::VERSION, ProjectFormat::CHANGES[self::VERSION], $changes, $writes, $followUps); + } + + public function applyPlan(ProjectUpgradeContext $context, ProjectUpgradePlan $plan): ProjectUpgradeReport + { + return ProjectUpgradeReport::applyPlan($context, $plan); + } + + /** + * Converts one map's layers and event layer and compares its collision. + * + * @param array|null $dictionary + * @param array $writes + * @param list $followUps + * @param array{files: int, changed: int, padded: int, moved: int} $summary + * @return array{comparison: MapCollisionComparison, events: list>, glyphs: list>}|null + * The converted map's collision comparison, event cells and composed + * glyphs; null when it does not load or has no collision dictionary. + */ + private function convertMap( + ProjectUpgradeContext $context, + ProjectMap $map, + ?array $dictionary, + array &$writes, + array &$followUps, + array &$summary, + ): ?array { + $layers = []; + $originalWidths = []; + $loads = true; + + foreach ($map->layerPaths as $path => $identity) { + $converted = $this->convertGrid($context, $path, $writes, $followUps, $summary); + + if ($converted === null) { + $loads = false; + continue; + } + + try { + $layers[] = new MapLayer($identity['name'], $identity['order'], $identity['decoration'], $context->getRelativePath($path), $converted['text']); + } catch (InvalidArgumentException $error) { + $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, $error->getMessage(), true); + $loads = false; + } + + if (! $identity['decoration']) { + foreach ($converted['originalWidths'] as $row => $width) { + $originalWidths[$row] = max($originalWidths[$row] ?? 0, $width); + } + } + } + + $eventGrid = null; + + if ($map->eventPath !== null) { + $converted = $this->convertGrid($context, $map->eventPath, $writes, $followUps, $summary); + $eventGrid = $converted === null ? null : $this->readEventGrid($context->getRelativePath($map->eventPath), $converted['text'], $followUps); + } + + if (! $loads || $layers === []) { + return null; + } + + try { + $set = new MapLayerSet($layers, $map->legacy); + if ($eventGrid !== null) { + $set->assertMatchingGrid($eventGrid, 'Event map ' . $context->getRelativePath((string) $map->eventPath)); + } + } catch (InvalidArgumentException $error) { + $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, "{$map->id}: {$error->getMessage()}", true); + return null; + } + + if ($dictionary === null) { + return null; + } + + try { + $comparison = new MapCollisionComparison($set, $dictionary, $originalWidths); + } catch (InvalidArgumentException $error) { + $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, "{$map->id}: collision could not be compared: {$error->getMessage()}"); + return null; + } + + $events = []; + foreach ($eventGrid ?? [] as $y => $row) { + foreach ($row as $x => $cell) { + $marker = $this->readMarker($cell); + if ($marker !== null && $comparison->isNewlySolidCell($x, $y)) { + $events[$marker][$y][] = $x; + } + } + } + + foreach ($events as $marker => $markerCells) { + $followUps[] = new ProjectUpgradeFollowUp(self::SOLID_POSITIONS, sprintf( + '%s: event %s covers newly solid cells (%s).', + $map->id, + $marker, + $this->describeCells($markerCells), + )); + } + + return [ + 'comparison' => $comparison, + 'events' => $eventGrid ?? [], + 'glyphs' => array_map(static fn(array $row): array => array_map(TerminalText::stripAnsi(...), $row), $set->getComposedGrid()), + ]; + } + + /** + * Compares where the player can walk on each map before and after, from + * the map's entry points, and reports what can no longer be reached. + * + * @param array>, glyphs: list>}|null> $states + * @param list $positions + * @param list $areas + * @param list $followUps + */ + private function compareReachability(array $states, array $positions, array $areas, array &$followUps): void + { + foreach ($states as $mapId => $state) { + if ($state === null) { + continue; + } + + $entries = $targets = []; + + foreach ($positions as $position) { + if ($position['map'] !== $mapId) { + continue; + } + if ($position['npc']) { + $targets[] = ['label' => "NPC at {$position['label']}", 'columns' => [[$position['x'], $position['y']]]]; + } else { + $entries[] = ['x' => $position['x'], 'y' => $position['y'], 'label' => $position['label']]; + } + } + + foreach ($areas as $area) { + if ($area['map'] !== $mapId) { + continue; + } + $columns = []; + for ($y = $area['y']; $y < $area['y'] + $area['height']; $y++) { + for ($x = $area['x']; $x < $area['x'] + $area['width']; $x++) { + $columns[] = [$x, $y]; + } + } + $targets[] = ['label' => "trigger at {$area['label']}", 'columns' => $columns]; + } + + foreach ($this->getEventColumns($state['events']) as $marker => $columns) { + $targets[] = ['label' => "event {$marker}", 'columns' => $columns]; + } + + $solid = count(array_merge([], ...array_values($state['comparison']->getNewlySolidCells()))); + $followUps[] = new ProjectUpgradeFollowUp(self::SOLID_CELLS, sprintf( + '%s: %d %s became solid; %s.', + $mapId, + $solid, + $solid === 1 ? 'cell' : 'cells', + $entries === [] + ? 'no entry point is known, so reachability was not compared' + : sprintf('reachability was compared from %d entry %s', count($entries), count($entries) === 1 ? 'point' : 'points'), + )); + + if ($entries === []) { + continue; + } + + $result = new MapReachabilityComparison($state['comparison'])->compareReachability($entries, $targets); + + foreach ($result['targets'] as $label) { + $followUps[] = new ProjectUpgradeFollowUp(self::UNREACHABLE, "{$mapId}: {$label} can no longer be reached from the map's entry points."); + } + + foreach ($result['entries'] as $entry) { + $followUps[] = new ProjectUpgradeFollowUp(self::UNREACHABLE, sprintf( + '%s: the arrival at %s no longer connects to %d other %s it reached before, such as %s.', + $mapId, + $entry['label'], + $entry['lost'], + $entry['lost'] === 1 ? 'entry point' : 'entry points', + $entry['example'], + )); + } + + foreach ($result['regions'] as $region) { + [$x, $y] = $region['cell']; + $choke = $region['choke'] === null + ? 'no single newly solid cell borders it' + : sprintf('likely closed by cell (%d, %d), "%s", which became solid', $region['choke'][0], $region['choke'][1], $state['glyphs'][$region['choke'][1]][$region['choke'][0]] ?? ''); + $followUps[] = new ProjectUpgradeFollowUp(self::CUT_OFF, sprintf( + '%s: %d walkable %s around cell (%d, %d) can no longer be reached; %s.', + $mapId, + $region['size'], + $region['size'] === 1 ? 'cell' : 'cells', + $x, + $y, + $choke, + )); + } + } + } + + /** + * The old columns each event marker occupied, read from its converted cells. + * + * @param list> $events + * @return array> + */ + private function getEventColumns(array $events): array + { + $columns = []; + + foreach ($events as $y => $row) { + foreach ($row as $x => $cell) { + $marker = $this->readMarker($cell); + if ($marker === null) { + continue; + } + $characters = MapCell::getCharacters($cell); + foreach ($characters as $index => $character) { + if ($character === $marker || count($characters) === 1) { + $columns[$marker][] = [$x * MapCell::COLUMNS + (count($characters) === 1 ? 0 : $index), $y]; + } + } + } + } + + return $columns; + } + + /** + * @param array $writes + * @param list $followUps + * @param array{files: int, changed: int, padded: int, moved: int} $summary + * @return array{text: string, originalWidths: list}|null + */ + private function convertGrid(ProjectUpgradeContext $context, string $path, array &$writes, array &$followUps, array &$summary): ?array + { + $relative = $context->getRelativePath($path); + $source = (string) file_get_contents($path); + $summary['files']++; + + try { + $converted = $this->grids->convertSource($source, $relative); + } catch (InvalidArgumentException $error) { + $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, "{$relative}: {$error->getMessage()}", true); + return null; + } + + if ($converted['source'] !== $source) { + $writes[$path] = $converted['source']; + $summary['changed']++; + } + + $summary['padded'] += $converted['paddedRows']; + $summary['moved'] += count($converted['insertions']); + + foreach ($converted['insertions'] as $insertion) { + $followUps[] = new ProjectUpgradeFollowUp(self::MOVED_GLYPHS, sprintf( + '%s row %d, column %d: a space was inserted before %s so it starts a cell; the rest of the row moved one column right, so check it against the map\'s other layers.', + $relative, + $insertion['row'], + $insertion['column'], + $insertion['glyph'], + )); + } + + foreach ($converted['errors'] as $error) { + $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, "{$relative} {$error}.", true); + } + + return $converted['errors'] === [] ? $converted : null; + } + + /** + * Reads a converted event layer, reporting each cell with two different markers. + * + * @param list $followUps + * @return list>|null + */ + private function readEventGrid(string $relative, string $text, array &$followUps): ?array + { + try { + $grid = MapLayer::parseGrid($text, $relative); + } catch (InvalidArgumentException $error) { + $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, $error->getMessage(), true); + return null; + } + + foreach ($grid as $y => $row) { + foreach ($row as $x => $cell) { + try { + MapCell::getMarker($cell, "{$relative} row {$y}, cell {$x}"); + } catch (InvalidArgumentException $error) { + $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, $error->getMessage() . ' Keep one marker per cell.', true); + } + } + } + + return $grid; + } + + private function readMarker(string $cell): ?string + { + try { + return MapCell::getMarker($cell); + } catch (InvalidArgumentException) { + return null; + } + } + + /** + * Every authored file that can hold field coordinates, with what it holds. + * + * @param array $maps + * @return array Kind and map id by path. + */ + private function findCoordinateFiles(ProjectUpgradeContext $context, array $maps): array + { + $files = []; + + foreach ($maps as $map) { + $files[$map->dataPath] = [FieldCoordinateConverter::MAP_DATA, $map->id]; + } + + foreach ($this->findPhpFiles($context->getPath(self::EVENTS_DIRECTORY)) as $path) { + $files[$path] = [FieldCoordinateConverter::COMMANDS, null]; + } + + foreach ($this->findPhpFiles($context->getPath(self::CINEMATICS_DIRECTORY)) as $path) { + $files[$path] = [str_ends_with($path, '.data.php') ? FieldCoordinateConverter::CINEMATIC : FieldCoordinateConverter::COMMANDS, null]; + } + + $system = $context->getPath(self::SYSTEM_PATH); + if (is_file($system)) { + $files[$system] = [FieldCoordinateConverter::SYSTEM, null]; + } + + return $files; + } + + /** @return list */ + private function findPhpFiles(string $directory): array + { + if (! is_dir($directory)) { + return []; + } + + $paths = []; + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) { + if (str_ends_with($file->getFilename(), '.php')) { + $paths[] = $file->getPathname(); + } + } + sort($paths); + + return $paths; + } + + /** + * @param array $comparisons + * @param array $writes + * @param list $followUps + * @param array{halved: int, files: int, steps: int, crops: int} $summary + * @param array>> $npcs NPC labels by map and new cell, then old column. + * @return array{positions: list, areas: list} + */ + private function convertCoordinates( + ProjectUpgradeContext $context, + string $path, + string $kind, + ?string $mapId, + array $comparisons, + array &$writes, + array &$followUps, + array &$summary, + array &$npcs, + ): array { + $relative = $context->getRelativePath($path); + $source = (string) file_get_contents($path); + + try { + $converted = $this->coordinates->convertSource($source, $relative, $kind, $mapId); + } catch (InvalidArgumentException $error) { + $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, "{$relative}: the file could not be read ({$error->getMessage()}); halve its field x coordinates by hand."); + return ['positions' => [], 'areas' => []]; + } + + $updated = $converted['source']; + $summary['halved'] += $converted['halved']; + $summary['steps'] += count($converted['steps']); + + foreach ($converted['manual'] as $message) { + $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, $message); + } + + foreach ($converted['steps'] as $message) { + $followUps[] = new ProjectUpgradeFollowUp(self::ROUTE_STEPS, $message); + } + + foreach ($converted['positions'] as $position) { + if ($position['npc']) { + $cell = sprintf('%s cell (%d, %d)', $position['map'], FieldCoordinateConverter::getCell($position['x']), $position['y']); + $npcs[$cell][$position['x']][] = $position['label']; + } + + $newlySolid = ($comparisons[$position['map']] ?? null)?->isNewlySolidColumn($position['x'], $position['y']); + if ($newlySolid === true) { + $followUps[] = new ProjectUpgradeFollowUp(self::SOLID_POSITIONS, sprintf( + '%s now stands on solid cell (%d, %d) of %s.', + $position['label'], + FieldCoordinateConverter::getCell($position['x']), + $position['y'], + $position['map'], + )); + } + } + + if ($converted['tiles2d']) { + try { + $document = PhpArraySourceDocument::parse($updated); + $updated = $document->withEdits([$document->removeEntryEdit(['tiles2d'])])->source; + $summary['crops']++; + $followUps[] = new ProjectUpgradeFollowUp(self::TILE_CROPS, "{$mapId}: tiles2d was removed; the map shows its terminal glyphs in graphical renderers until it has a tileset."); + } catch (Throwable $error) { + $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, "{$relative}: remove the retired tiles2d entry by hand ({$error->getMessage()}); the engine no longer reads it."); + } + } + + if ($updated !== $source) { + $writes[$path] = $updated; + $summary['files']++; + } + + return ['positions' => $converted['positions'], 'areas' => $converted['areas']]; + } + + /** + * @param array $writes + * @param list $followUps + */ + private function planSaveMigration(ProjectUpgradeContext $context, array &$writes, array &$followUps): string + { + $path = $context->getPath(SaveMetadataStep::MANIFEST_PATH); + // Format 1 creates the manifest; planned before it runs, start from what it will write. + $source = is_file($path) ? (string) file_get_contents($path) : SaveCompatibilityMetadata::renderBaseline(); + + try { + $migration = $this->saves->appendMigration($source, TwoColumnCellsMigration::class); + } catch (Throwable $error) { + $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, sprintf( + '%s: %s Register %s as the last content migration by hand, or saved games will place the player in the wrong column.', + SaveMetadataStep::MANIFEST_PATH, + $error->getMessage(), + TwoColumnCellsMigration::class, + )); + + return 'Leave the save compatibility manifest unchanged (it could not be read; see the report).'; + } + + if (! $migration['added']) { + return 'Keep the save migration for two-column cells already registered in the save compatibility manifest.'; + } + + $writes[$path] = $migration['source']; + + return sprintf( + 'Add save migration %d to %d (TwoColumnCellsMigration), so saved games reopen in the cell that holds their column.', + $migration['from'], + $migration['to'], + ); + } + + /** + * @param list $followUps + * @return array|null + */ + private function loadCollisionDictionary(ProjectUpgradeContext $context, array &$followUps): ?array + { + $path = $context->getPath(self::COLLISIONS_PATH); + + if (! is_file($path)) { + return null; + } + + try { + // The dictionary is authored PHP the engine requires the same way; grids are never evaluated. + $dictionary = (static fn(string $file): mixed => require $file)($path); + } catch (Throwable $error) { + $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, sprintf('%s could not be loaded (%s), so cells that became solid were not checked.', self::COLLISIONS_PATH, $error->getMessage())); + return null; + } + + if (! is_array($dictionary)) { + $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, self::COLLISIONS_PATH . ' does not return an array, so cells that became solid were not checked.'); + return null; + } + + return $dictionary; + } + + /** @param array> $cells Cell columns by row. */ + private function describeCells(array $cells): string + { + ksort($cells); + $rows = []; + + foreach ($cells as $y => $columns) { + $rows[] = sprintf('row %d: %s %s', $y, count($columns) === 1 ? 'cell' : 'cells', implode(', ', $columns)); + } + + return implode('; ', $rows); + } +} diff --git a/src/Upgrade/TwoColumnCells/FieldCoordinateConverter.php b/src/Upgrade/TwoColumnCells/FieldCoordinateConverter.php new file mode 100644 index 0000000..8ea7ddf --- /dev/null +++ b/src/Upgrade/TwoColumnCells/FieldCoordinateConverter.php @@ -0,0 +1,373 @@ +.data.php`. */ + public const string MAP_DATA = 'map data'; + /** A command list: common events and cinematic scripts. */ + public const string COMMANDS = 'commands'; + /** A cinematic definition: its cast and finalizer. */ + public const string CINEMATIC = 'cinematic'; + /** assets/Data/system.php: only its start positions hold field coordinates. */ + public const string SYSTEM = 'system'; + + private const array STEP_FIELDS = ['direction', 'count', 'faceOnly', 'seconds']; + private const array HORIZONTAL_DIRECTIONS = ['left', 'right']; + + /** + * Converts one file. + * + * @param string $source The file's bytes. + * @param string $displayPath The project-relative path, for messages. + * @param string $kind What the file holds: one of the kind constants. + * @param string|null $mapId The map a map data file describes. + * @return array{ + * source: string, + * halved: int, + * steps: list, + * review: list, + * manual: list, + * positions: list, + * areas: list, + * tiles2d: bool + * } + * The rewritten source; how many coordinates changed; each horizontal + * move route step, described; values a person must convert; positions + * to check against the new collision, by map, with their old column + * and whether each is an NPC; trigger areas on the map, in old columns; + * and whether the file declares tile crops. + * @throws InvalidArgumentException When the file is not valid PHP. + */ + public function convertSource(string $source, string $displayPath, string $kind, ?string $mapId = null): array + { + $file = new PhpLiteralScanner()->scanSource($source, $displayPath); + $result = ['halved' => 0, 'steps' => [], 'review' => [], 'manual' => [], 'positions' => [], 'areas' => [], 'tiles2d' => false]; + $edits = []; + + foreach ($file->getArrays() as $array) { + if ($kind === self::MAP_DATA && $array->parent === null && $array->getEntry('tiles2d') !== null) { + $result['tiles2d'] = true; + } + + if ($this->isOutsideFieldData($array, $kind)) { + continue; + } + + $this->convertStep($array, $displayPath, $edits, $result); + + if ($array->getEntry('x') === null) { + continue; + } + + $role = $this->getRole($array, $kind); + $where = sprintf('%s:%d %s', $displayPath, $array->getEntry('x')->line, $this->describe($array)); + + match ($role) { + 'point' => $this->convertPoint($array, $where, $edits, $result), + 'rect' => $this->convertRect($array, $where, $edits, $result), + 'screen', 'region' => null, + default => $result['manual'][] = "{$where}: an x the upgrade could not identify as a field coordinate was left unchanged; halve it if it is one.", + }; + + $column = $array->getEntry('x')->integer; + $row = $array->getEntry('y')?->integer; + + if ($role === 'rect' && $mapId !== null && $column !== null && $row !== null && $array->parentKey !== 'wanderArea') { + $width = $array->getEntry('width') === null ? 1 : $array->getEntry('width')->integer; + $height = $array->getEntry('height') === null ? 1 : $array->getEntry('height')->integer; + if ($width !== null && $height !== null) { + $result['areas'][] = ['map' => $mapId, 'x' => $column, 'y' => $row, 'width' => $width, 'height' => $height, 'label' => $where]; + } + } + + $target = $role === 'point' ? $this->getPositionMap($array, $kind, $mapId) : null; + + if (is_string($target) && $column !== null && $row !== null) { + $result['positions'][] = [ + 'map' => $target, + 'x' => $column, + 'y' => $row, + 'label' => $where, + 'npc' => $kind === self::MAP_DATA && $this->isListedSubject($array, $kind), + ]; + } + } + + usort($edits, static fn(array $a, array $b): int => $b[0] <=> $a[0]); + foreach ($edits as [$start, $end, $text]) { + $source = substr($source, 0, $start) . $text . substr($source, $end); + } + + // The rewritten file must still be valid PHP. + new PhpLiteralScanner()->scanSource($source, $displayPath); + + return ['source' => $source] + $result; + } + + /** Arrays whose x values are not field positions at all: pixel crops, and data outside start positions. */ + private function isOutsideFieldData(PhpLiteral $array, string $kind): bool + { + return match ($kind) { + self::MAP_DATA => $array->isUnderKey('tiles2d'), + self::SYSTEM => ! $array->isUnderKey('startingPositions'), + default => false, + }; + } + + /** + * What an array holding an `x` is: a field point, a field rectangle, a + * screen position, or unknown. + */ + private function getRole(PhpLiteral $array, string $kind): string + { + $type = $array->getString('type'); + $parent = $array->parent; + + if ($type !== null && in_array($type, self::getPositionCommandTypes(), true)) { + return 'point'; + } + + $subjectKind = $array->getString('kind'); + if ($subjectKind === 'position') { + return 'point'; + } + if ($subjectKind === 'screen_position') { + return 'screen'; + } + + if ($array->parentKey === 'actor' && $parent !== null && $parent->getString('type') === 'stage_actor') { + return 'point'; + } + + if ($parent !== null && $parent->parentKey === 'waypoints') { + return 'point'; + } + + if (in_array($array->parentKey, ['spawnPoint', 'spawn_point'], true)) { + return 'point'; + } + + if (in_array($array->parentKey, ['wanderArea', 'trigger_area'], true) + || ($kind === self::MAP_DATA && $array->parentKey === 'area')) { + return 'rect'; + } + + if ($this->isListedSubject($array, $kind)) { + return 'point'; + } + + // A region map station places the map on its region's schematic, not on the field. + if ($kind === self::MAP_DATA && $array->parentKey === 'station' && $parent?->parent === null) { + return 'region'; + } + + return 'unknown'; + } + + /** + * Whether an array is one of a map's NPCs or a cinematic's cast: an entry + * of the top-level `npcs` or `cast` list, or an array with the subject's + * required name or id written inside such an entry (built by a helper + * closure and spread into the list, for example). + */ + private function isListedSubject(PhpLiteral $array, string $kind): bool + { + $listKey = match ($kind) { + self::MAP_DATA => 'npcs', + self::CINEMATIC => 'cast', + default => null, + }; + $list = $array->enclosingArray; + + if ($listKey === null || $list === null || $list->parentKey !== $listKey + || $list->parent === null || $list->parent->enclosingArray !== null) { + return false; + } + + return $array->parent === $list || $array->getEntry('name') !== null || $array->getEntry('id') !== null; + } + + /** + * Command types that carry a field x of their own: those whose finalizer + * shape requires one, and staging an actor, whose fields include one. + * + * @return list + */ + private static function getPositionCommandTypes(): array + { + $types = []; + foreach (CinematicCommandSchema::FINALIZER_COMMAND_SHAPES as $type => $shape) { + if (array_key_exists('x', $shape['required'] ?? []) || array_key_exists('x', $shape['optional'] ?? [])) { + $types[] = $type; + } + } + if (in_array('x', CinematicCommandSchema::STAGED_ACTOR_FIELDS, true)) { + $types[] = 'stage_actor'; + } + + return $types; + } + + /** + * The map a point stands on, when it should be checked against the new + * collision: a transfer's map, a spawn point's destination (or its own + * map), an NPC's map. Null when the point is not checked. + */ + private function getPositionMap(PhpLiteral $array, string $kind, ?string $mapId): ?string + { + if ($array->getString('type') === 'transfer') { + return $array->getString('map'); + } + + if (in_array($array->parentKey, ['spawnPoint', 'spawn_point'], true)) { + return $array->parent?->getString('destinationMap') ?? ($kind === self::MAP_DATA ? $mapId : null); + } + + if ($kind === self::MAP_DATA && $this->isListedSubject($array, $kind)) { + return $mapId; + } + + return null; + } + + /** @param list $edits */ + private function convertPoint(PhpLiteral $array, string $where, array &$edits, array &$result): void + { + $x = $array->getEntry('x'); + $value = $x->integer; + + if ($value === null) { + $result['manual'][] = "{$where}: x is not an integer literal; halve it by hand (rounding down)."; + return; + } + + $this->replaceInteger($x, self::getCell($value), $edits, $result); + } + + /** + * A rectangle keeps covering the cells its old columns covered: its x is + * halved and its width becomes the cells from its first column to its last. + * + * @param list $edits + */ + private function convertRect(PhpLiteral $array, string $where, array &$edits, array &$result): void + { + $x = $array->getEntry('x'); + $width = $array->getEntry('width'); + $left = $x->integer; + $columns = $width === null ? 1 : $width->integer; + + if ($left === null || $columns === null) { + $result['manual'][] = "{$where}: x or width is not an integer literal; convert the area by hand (x rounds down, width covers the same columns)."; + return; + } + + $this->replaceInteger($x, self::getCell($left), $edits, $result); + + if ($width !== null) { + $this->replaceInteger($width, self::getCell($left + $columns - 1) - self::getCell($left) + 1, $edits, $result); + } + } + + /** + * Halves a horizontal move route step count. The exact count depends on + * the column the route starts from at runtime, so every horizontal step + * is reported with both possible distances. + * + * @param list $edits + */ + private function convertStep(PhpLiteral $array, string $displayPath, array &$edits, array &$result): void + { + $direction = $array->getString('direction'); + + if ($direction === null || ! in_array($direction, MovementRouteRunner::DIRECTIONS, true) + || array_diff(array_keys($array->entries), self::STEP_FIELDS) !== [] || $array->embedded !== []) { + return; + } + + if (! in_array($direction, self::HORIZONTAL_DIRECTIONS, true) || $array->getEntry('faceOnly')?->boolean === true) { + return; + } + + $where = sprintf('%s:%d %s', $displayPath, $array->getEntry('direction')->line, $this->describe($array)); + $count = $array->getEntry('count'); + $columns = $count === null ? 1 : $count->integer; + + if ($columns === null) { + $result['manual'][] = "{$where}: the {$direction} step count is not an integer literal; halve it by hand."; + return; + } + + $cells = $columns <= 0 ? $columns : max(1, intdiv($columns, MapCell::COLUMNS)); + $shortest = intdiv($columns, MapCell::COLUMNS); + $longest = intdiv($columns + 1, MapCell::COLUMNS); + + if ($count !== null) { + $this->replaceInteger($count, $cells, $edits, $result, countAsCoordinate: false); + } + + $result['steps'][] = sprintf( + '%s: %s %d %s now %d %s; %s.', + $where, + $direction, + $columns, + $columns === 1 ? 'step is' : 'steps are', + $cells, + $cells === 1 ? 'cell' : 'cells', + $shortest === $longest + ? sprintf('the same distance is %d %s from any starting column', $shortest, $shortest === 1 ? 'cell' : 'cells') + : sprintf('the same distance is %d or %d cells, depending on the starting column', $shortest, $longest), + ); + } + + /** @param list $edits */ + private function replaceInteger(PhpLiteral $literal, int $value, array &$edits, array &$result, bool $countAsCoordinate = true): void + { + if ($literal->integer === $value) { + return; + } + + $edits[] = [$literal->start, $literal->end, (string) $value]; + + if ($countAsCoordinate) { + $result['halved']++; + } + } + + /** The cell holding a one-column x. */ + public static function getCell(int $column): int + { + return intdiv($column - ($column < 0 ? MapCell::COLUMNS - 1 : 0), MapCell::COLUMNS); + } + + private function describe(PhpLiteral $array): string + { + $path = $array->getPath(); + $type = $array->getString('type'); + + if ($path === '') { + return $type !== null ? "({$type})" : ''; + } + + return $type !== null ? "{$path} ({$type})" : $path; + } +} diff --git a/src/Upgrade/TwoColumnCells/GridSourceConverter.php b/src/Upgrade/TwoColumnCells/GridSourceConverter.php new file mode 100644 index 0000000..0e88246 --- /dev/null +++ b/src/Upgrade/TwoColumnCells/GridSourceConverter.php @@ -0,0 +1,134 @@ +, + * paddedRows: int, + * insertions: list, + * errors: list + * } + * @throws InvalidArgumentException When the file is not a literal grid. + */ + public function convertSource(string $source, string $displayPath): array + { + $originalText = MapGridSource::parseSource($source, $displayPath); + $content = $this->findBodySpan($source); + $indent = $content['indent']; + $raw = substr($source, $content['start'], $content['end'] - $content['start']); + $parts = preg_split('/(\r\n|\n|\r)/', $raw, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$raw]; + // PHP drops the line break before the closing marker, so the part + // after it is the (empty) remainder, not a row. + $rowCount = intdiv(count($parts) - 1, 2); + $paddedRows = 0; + $insertions = []; + $errors = []; + $originalWidths = []; + + for ($row = 0; $row < $rowCount; $row++) { + $line = $parts[$row * 2]; + $prefix = ''; + + if ($indent !== '') { + if (str_starts_with($line, $indent)) { + $prefix = $indent; + $line = substr($line, strlen($indent)); + } elseif (trim($line, " \t") === '') { + $prefix = $line; + $line = ''; + } + } + + $converted = $this->rows->convertRow($line); + $originalWidths[] = $converted['width'] - ($converted['padded'] ? 1 : 0) - count($converted['insertions']); + + if ($converted['error'] !== null) { + $errors[] = "row {$row}: {$converted['error']}"; + } + + if ($converted['padded']) { + $paddedRows++; + } + + foreach ($converted['insertions'] as $insertion) { + $insertions[] = ['row' => $row] + $insertion; + } + + $parts[$row * 2] = $prefix . $converted['text']; + } + + $updated = substr($source, 0, $content['start']) . implode('', $parts) . substr($source, $content['end']); + $text = MapGridSource::parseSource($updated, $displayPath); + + if ($errors === []) { + // The engine must read every converted row as whole cells. + MapLayer::parseGrid($text, $displayPath); + } + + return [ + 'source' => $updated, + 'originalText' => $originalText, + 'text' => $text, + 'originalWidths' => $originalWidths, + 'paddedRows' => $paddedRows, + 'insertions' => $insertions, + 'errors' => $errors, + ]; + } + + /** + * The byte span of the nowdoc body, including the line break before the + * closing marker, and the closing marker's indentation. + * + * @return array{start: int, end: int, indent: string} + */ + private function findBodySpan(string $source): array + { + $tokens = PhpToken::tokenize($source); + + foreach ($tokens as $index => $token) { + if (! $token->is(T_START_HEREDOC)) { + continue; + } + + $start = $token->pos + strlen($token->text); + $next = $tokens[$index + 1] ?? null; + $endToken = $next !== null && $next->is(T_ENCAPSED_AND_WHITESPACE) ? ($tokens[$index + 2] ?? null) : $next; + + if ($endToken === null || ! $endToken->is(T_END_HEREDOC)) { + break; + } + + preg_match('/\A[ \t]*/', $endToken->text, $matches); + + return ['start' => $start, 'end' => $endToken->pos, 'indent' => $matches[0] ?? '']; + } + + throw new InvalidArgumentException('The grid source has no nowdoc body.'); + } +} diff --git a/src/Upgrade/TwoColumnCells/MapCollisionComparison.php b/src/Upgrade/TwoColumnCells/MapCollisionComparison.php new file mode 100644 index 0000000..e18e100 --- /dev/null +++ b/src/Upgrade/TwoColumnCells/MapCollisionComparison.php @@ -0,0 +1,137 @@ +> The engine's collision per cell. */ + public array $cells; + /** @var list> The one-column collision per original column. */ + public array $columns; + + /** + * @param MapLayerSet $layers The converted layers. + * @param array $dictionary The project's collision dictionary. + * @param list $originalWidths Each row's width before conversion, in columns. + */ + public function __construct(MapLayerSet $layers, array $dictionary, array $originalWidths) + { + $this->cells = MapCollisionResolver::resolveLayers($layers, $dictionary); + $flat = array_filter($dictionary, static fn(mixed $value): bool => $value instanceof CollisionType); + $gameplay = []; + + foreach ($layers->layers as $layer) { + if (! $layer->decoration) { + $section = $dictionary[$layer->name] ?? []; + $gameplay[] = [$layer, (is_array($section) ? $section : []) + $flat]; + } + } + + $columns = []; + + foreach ($this->cells as $y => $row) { + $columns[$y] = []; + $width = $originalWidths[$y] ?? count($row) * MapCell::COLUMNS; + + for ($column = 0; $column < $width; $column++) { + $cell = intdiv($column, MapCell::COLUMNS); + $kind = CollisionType::SOLID; + + for ($index = count($gameplay) - 1; $index >= 0; $index--) { + [$layer, $types] = $gameplay[$index]; + $characters = MapCell::getCharacters($layer->glyphs[$y][$cell] ?? MapCell::BLANK); + // A two-column glyph was one character covering both columns. + $character = $characters[count($characters) === 1 ? 0 : $column % MapCell::COLUMNS] ?? ' '; + + if ($index !== 0 && trim($character) === '') { + continue; + } + + $type = MapCollisionResolver::resolveCell($character, $types); + + if ($type !== CollisionType::PASS_THROUGH) { + $kind = $type; + break; + } + } + + $columns[$y][$column] = $kind->value; + } + } + + $this->columns = $columns; + } + + /** + * Cells that are solid now although a column they cover was not: a wall + * character paired with a floor character, such as a one-column doorway. + * + * @return array> Cell columns by row. + */ + public function getNewlySolidCells(): array + { + $cells = []; + + foreach ($this->cells as $y => $row) { + foreach ($row as $x => $kind) { + if ($this->isNewlySolidCell($x, $y)) { + $cells[$y][] = $x; + } + } + } + + return $cells; + } + + /** Whether the engine lets the player walk onto ground of this kind (MapManager::canMoveTo). */ + public static function isWalkable(?int $kind): bool + { + return $kind !== null && $kind !== CollisionType::SOLID->value && $kind !== CollisionType::NPC->value; + } + + /** Whether a cell is solid now although a column it covers was not. */ + public function isNewlySolidCell(int $x, int $y): bool + { + if (($this->cells[$y][$x] ?? null) !== CollisionType::SOLID->value) { + return false; + } + + foreach ([$x * MapCell::COLUMNS, $x * MapCell::COLUMNS + 1] as $column) { + if (isset($this->columns[$y][$column]) && $this->columns[$y][$column] !== CollisionType::SOLID->value) { + return true; + } + } + + return false; + } + + /** + * Whether something that stood on an old column now stands on a solid + * cell although that column was not solid. Null when the position is + * outside the map. + */ + public function isNewlySolidColumn(int $column, int $y): ?bool + { + $cell = FieldCoordinateConverter::getCell($column); + + if (! isset($this->cells[$y][$cell])) { + return null; + } + + return $this->cells[$y][$cell] === CollisionType::SOLID->value + && ($this->columns[$y][$column] ?? CollisionType::SOLID->value) !== CollisionType::SOLID->value; + } +} diff --git a/src/Upgrade/TwoColumnCells/MapReachabilityComparison.php b/src/Upgrade/TwoColumnCells/MapReachabilityComparison.php new file mode 100644 index 0000000..8e55aba --- /dev/null +++ b/src/Upgrade/TwoColumnCells/MapReachabilityComparison.php @@ -0,0 +1,233 @@ + $entries Arrival points, by old column. + * @param list}> $targets Things to reach, + * by the old columns they occupy: event areas, NPCs and transfer triggers. A target is + * reachable when one of its positions, or a position beside one, is reachable. + * @return array{ + * targets: list, + * entries: list, + * regions: list + * } + * Targets reachable before but not after; entries that no longer connect to + * other entries they connected to before; and walkable regions that were + * reachable before and are now cut off, each with a representative cell and + * the newly solid cell that most likely closed it. + */ + public function compareReachability(array $entries, array $targets): array + { + $entries = $this->removeDuplicateEntries($entries); + $oldFloods = $newFloods = []; + + foreach ($entries as $index => $entry) { + $oldFloods[$index] = $this->flood($this->collision->columns, [[$entry['x'], $entry['y']]]); + $newFloods[$index] = $this->flood($this->collision->cells, [[FieldCoordinateConverter::getCell($entry['x']), $entry['y']]]); + } + + $oldReach = $oldFloods === [] ? [] : array_replace(...$oldFloods); + $newReach = $newFloods === [] ? [] : array_replace(...$newFloods); + $lostTargets = []; + + foreach ($targets as $target) { + $cells = array_map(static fn(array $column): array => [FieldCoordinateConverter::getCell($column[0]), $column[1]], $target['columns']); + if ($this->isReached($oldReach, $target['columns']) && ! $this->isReached($newReach, $cells)) { + $lostTargets[] = $target['label']; + } + } + + $lostEntries = []; + + // Each pair of entries is compared once; an entry on ground it cannot + // stand on still connects through the ground beside it. + foreach ($entries as $index => $entry) { + $lost = []; + foreach (array_slice($entries, $index + 1, preserve_keys: true) as $otherIndex => $other) { + if ($this->isReached($oldFloods[$index], [[$other['x'], $other['y']]]) + && ! $this->isReached($newFloods[$index], [[FieldCoordinateConverter::getCell($other['x']), $other['y']]])) { + $lost[] = $other['label']; + } + } + if ($lost !== []) { + $lostEntries[] = ['label' => $entry['label'], 'lost' => count($lost), 'example' => $lost[0]]; + } + } + + return ['targets' => $lostTargets, 'entries' => $lostEntries, 'regions' => $this->findCutOffRegions($oldReach, $newReach)]; + } + + /** + * @param list $entries + * @return list One entry per old position. + */ + private function removeDuplicateEntries(array $entries): array + { + $unique = []; + foreach ($entries as $entry) { + $unique[self::getKey($entry['x'], $entry['y'])] ??= $entry; + } + + return array_values($unique); + } + + /** + * Walkable cells reachable from the seeds. A seed counts as reached even + * when it is not walkable, since the player is placed there and can step + * off it, as the engine only checks the ground being stepped onto. + * + * @param list> $grid Collision by row and column. + * @param list $seeds + * @return array + */ + private function flood(array $grid, array $seeds): array + { + $reached = []; + $queue = []; + + foreach ($seeds as [$x, $y]) { + if (isset($grid[$y][$x])) { + $reached[self::getKey($x, $y)] = true; + $queue[] = [$x, $y]; + } + } + + while ($queue !== []) { + [$x, $y] = array_pop($queue); + foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as [$dx, $dy]) { + $nextX = $x + $dx; + $nextY = $y + $dy; + $key = self::getKey($nextX, $nextY); + if (! isset($reached[$key]) && MapCollisionComparison::isWalkable($grid[$nextY][$nextX] ?? null)) { + $reached[$key] = true; + $queue[] = [$nextX, $nextY]; + } + } + } + + return $reached; + } + + /** + * @param array $reach + * @param list $positions + */ + private function isReached(array $reach, array $positions): bool + { + foreach ($positions as [$x, $y]) { + foreach ([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]] as [$dx, $dy]) { + if (isset($reach[self::getKey($x + $dx, $y + $dy)])) { + return true; + } + } + } + + return false; + } + + /** + * Walkable cells that are no longer reachable although a column they + * cover was, grouped into connected regions. + * + * @param array $oldReach + * @param array $newReach + * @return list + */ + private function findCutOffRegions(array $oldReach, array $newReach): array + { + $candidates = []; + + foreach ($this->collision->cells as $y => $row) { + foreach ($row as $x => $kind) { + $column = $x * MapCell::COLUMNS; + if (MapCollisionComparison::isWalkable($kind) && ! isset($newReach[self::getKey($x, $y)]) + && (isset($oldReach[self::getKey($column, $y)]) || isset($oldReach[self::getKey($column + 1, $y)]))) { + $candidates[self::getKey($x, $y)] = [$x, $y]; + } + } + } + + $regions = []; + + while ($candidates !== []) { + $start = reset($candidates); + unset($candidates[self::getKey(...$start)]); + $region = [$start]; + $queue = [$start]; + + while ($queue !== []) { + [$x, $y] = array_pop($queue); + foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as [$dx, $dy]) { + $key = self::getKey($x + $dx, $y + $dy); + if (isset($candidates[$key])) { + $region[] = $candidates[$key]; + $queue[] = $candidates[$key]; + unset($candidates[$key]); + } + } + } + + $regions[] = ['size' => count($region), 'cell' => $start, 'choke' => $this->findChokeCell($region, $newReach)]; + } + + return $regions; + } + + /** + * The newly solid cell that most likely cut a region off: one bordering + * both the region and the ground still reachable, else one bordering the + * region. + * + * @param list $region + * @param array $newReach + * @return array{int, int}|null + */ + private function findChokeCell(array $region, array $newReach): ?array + { + $fallback = null; + $members = []; + foreach ($region as [$x, $y]) { + $members[self::getKey($x, $y)] = true; + } + + foreach ($this->collision->cells as $y => $row) { + foreach ($row as $x => $kind) { + if (! $this->collision->isNewlySolidCell($x, $y)) { + continue; + } + $bordersRegion = $this->isReached($members, [[$x, $y]]); + if ($bordersRegion && $this->isReached($newReach, [[$x, $y]])) { + return [$x, $y]; + } + $fallback ??= $bordersRegion ? [$x, $y] : null; + } + } + + return $fallback; + } + + private static function getKey(int $x, int $y): string + { + return $x . ':' . $y; + } +} diff --git a/src/Upgrade/TwoColumnCells/MapRowConverter.php b/src/Upgrade/TwoColumnCells/MapRowConverter.php new file mode 100644 index 0000000..d30d7e0 --- /dev/null +++ b/src/Upgrade/TwoColumnCells/MapRowConverter.php @@ -0,0 +1,110 @@ +|\X/u'; + + /** + * @return array{text: string, width: int, padded: bool, insertions: list, error: ?string} + * The converted row; its new display width; whether it gained a + * trailing space; each glyph moved right, by its original column; and + * why the row could not be aligned, when it could not. + */ + public function convertRow(string $row): array + { + $symbols = TerminalText::visibleSymbols($row); + $widths = array_map(NormalizedRow::symbolWidth(...), $symbols); + $insertAt = []; + $column = 0; + + foreach ($widths as $index => $width) { + if ($width >= MapCell::COLUMNS && $column % MapCell::COLUMNS !== 0) { + $insertAt[$index] = $column; + $column++; + } + $column += $width; + } + + $text = $row; + $insertions = []; + + if ($insertAt !== []) { + $offsets = $this->findSymbolOffsets($row, $symbols); + + if ($offsets === null) { + return [ + 'text' => $row, + 'width' => array_sum($widths), + 'padded' => false, + 'insertions' => [], + 'error' => 'its markup could not be matched to its glyphs, so the space before a two-column glyph could not be placed', + ]; + } + + foreach (array_reverse($insertAt, true) as $index => $newColumn) { + $text = substr($text, 0, $offsets[$index]) . ' ' . substr($text, $offsets[$index]); + } + + $shift = 0; + foreach ($insertAt as $index => $newColumn) { + $insertions[] = ['column' => $newColumn - $shift, 'glyph' => TerminalText::stripAnsi($symbols[$index])]; + $shift++; + } + } + + $padded = $column % MapCell::COLUMNS !== 0; + + if ($padded) { + $text .= ' '; + $column++; + } + + return ['text' => $text, 'width' => $column, 'padded' => $padded, 'insertions' => $insertions, 'error' => null]; + } + + /** + * The byte offset in the raw row at which each visible symbol's grapheme + * begins, or null when the raw tokens do not match the symbols the engine + * reads (an escaped or unknown tag, for example). + * + * @param list $symbols + * @return list|null + */ + private function findSymbolOffsets(string $row, array $symbols): ?array + { + if (preg_match_all(self::TOKEN_PATTERN, $row, $matches, PREG_OFFSET_CAPTURE) === false) { + return null; + } + + $offsets = []; + $graphemes = []; + + foreach ($matches[0] as [$token, $offset]) { + if ($token === '' || str_starts_with($token, "\e") || preg_match('/\A<\/?[-\w=;#,?]+>\z/', $token) === 1) { + continue; + } + $offsets[] = $offset; + $graphemes[] = $token; + } + + return $graphemes === array_map(TerminalText::stripAnsi(...), $symbols) ? $offsets : null; + } +} diff --git a/src/Upgrade/TwoColumnCells/PhpLiteral.php b/src/Upgrade/TwoColumnCells/PhpLiteral.php new file mode 100644 index 0000000..6ba346c --- /dev/null +++ b/src/Upgrade/TwoColumnCells/PhpLiteral.php @@ -0,0 +1,168 @@ + Entries of an array by literal key, in source order. */ + public array $entries = []; + /** @var list Array literals written inside an expression. */ + public array $embedded = []; + /** The array holding this value, or null for an array written inside an expression or at file level. */ + public ?PhpLiteral $parent = null; + /** The key this value has in its parent array; null when it has none or it could not be read. */ + public int|string|null $parentKey = null; + /** The expression an array is written inside, when it has no parent array. */ + public ?PhpLiteral $owner = null; + + public function __construct( + public readonly string $kind, + public readonly int $start, + public readonly int $end, + public readonly int $line, + public readonly string $text, + ) { + } + + /** The value of an integer literal, or null for anything else. */ + public ?int $integer { + get { + if ($this->kind !== self::SCALAR) { + return null; + } + $text = str_replace([' ', "\t", "\n", "\r", '_'], '', $this->text); + $negative = str_starts_with($text, '-'); + $digits = ltrim($text, '-'); + if (preg_match('/\A(?:0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO]?[0-7]*|[1-9][0-9]*)\z/', $digits) !== 1) { + return null; + } + $value = intval(str_replace(['0o', '0O'], '0', $digits), 0); + return $negative ? -$value : $value; + } + } + + /** The value of a plain string literal, or null for anything else. */ + public ?string $string { + get { + if ($this->kind !== self::SCALAR || strlen($this->text) < 2) { + return null; + } + $quote = $this->text[0]; + $body = substr($this->text, 1, -1); + return match ($quote) { + "'" => strtr($body, ['\\\\' => '\\', "\\'" => "'"]), + '"' => stripcslashes($body), + default => null, + }; + } + } + + /** The value of `true` or `false`, or null for anything else. */ + public ?bool $boolean { + get => $this->kind === self::SCALAR ? match (strtolower($this->text)) { + 'true' => true, + 'false' => false, + default => null, + } : null; + } + + public function getEntry(int|string $key): ?PhpLiteral + { + return $this->entries[$key] ?? null; + } + + /** The string literal held under a key, or null. */ + public function getString(int|string $key): ?string + { + return $this->getEntry($key)?->string; + } + + /** Every array literal in this value and below it, this one included when it is an array. */ + public function getArrays(): \Generator + { + if ($this->kind === self::ARRAY) { + yield $this; + } + foreach ($this->entries as $entry) { + yield from $entry->getArrays(); + } + foreach ($this->embedded as $array) { + yield from $array->getArrays(); + } + } + + /** + * The array this value belongs to: its parent, or for an array written + * inside an expression, the array holding that expression. + */ + public ?PhpLiteral $enclosingArray { + get { + for ($node = $this; $node->parent === null; $node = $node->owner) { + if ($node->owner === null) { + return null; + } + } + + return $node->parent; + } + } + + /** The value in its enclosing array that this value is, or is written inside. */ + public PhpLiteral $enclosedValue { + get { + $node = $this; + while ($node->parent === null && $node->owner !== null) { + $node = $node->owner; + } + + return $node; + } + } + + /** Whether this value sits, at any depth, under one of the keys given. */ + public function isUnderKey(string ...$keys): bool + { + for ($node = $this; $node !== null; $node = $node->enclosedValue->parent) { + $value = $node->enclosedValue; + if (is_string($value->parentKey) && in_array($value->parentKey, $keys, true)) { + return true; + } + if ($value->parent === null) { + return false; + } + } + + return false; + } + + /** + * A readable path such as `npcs[2].wanderArea`, from the outermost array + * this value belongs to. An element whose key cannot be read, such as a + * spread, is shown as `[?]`. + */ + public function getPath(): string + { + $parts = []; + for ($node = $this->enclosedValue; $node->parent !== null; $node = $node->parent->enclosedValue) { + $parts[] = match (true) { + is_int($node->parentKey) => "[{$node->parentKey}]", + $node->parentKey === null => '[?]', + default => '.' . $node->parentKey, + }; + } + + return ltrim(implode('', array_reverse($parts)), '.'); + } +} diff --git a/src/Upgrade/TwoColumnCells/PhpLiteralScanner.php b/src/Upgrade/TwoColumnCells/PhpLiteralScanner.php new file mode 100644 index 0000000..6f89a36 --- /dev/null +++ b/src/Upgrade/TwoColumnCells/PhpLiteralScanner.php @@ -0,0 +1,230 @@ + */ + private array $tokens = []; + + /** + * The file's outermost values holding array literals. + * + * @return PhpLiteral An expression node whose embedded values are the file's outermost arrays. + * @throws InvalidArgumentException When the file is not valid PHP. + */ + public function scanSource(string $source, string $displayPath): PhpLiteral + { + try { + token_get_all($source, TOKEN_PARSE); + } catch (ParseError $error) { + throw new InvalidArgumentException(sprintf('%s has invalid PHP at line %d: %s', $displayPath, $error->getLine(), $error->getMessage()), previous: $error); + } + + $this->tokens = array_values(array_filter( + PhpToken::tokenize($source), + static fn(PhpToken $token): bool => ! $token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, T_OPEN_TAG, T_CLOSE_TAG, T_INLINE_HTML]), + )); + $file = new PhpLiteral(PhpLiteral::OTHER, 0, strlen($source), 1, ''); + $file->embedded = $this->collectArrays(0, count($this->tokens)); + foreach ($file->embedded as $array) { + $array->owner = $file; + } + $this->tokens = []; + + return $file; + } + + /** @return list The array literals in a token range that are not inside another one there. */ + private function collectArrays(int $from, int $to): array + { + $arrays = []; + + for ($index = $from; $index < $to; $index++) { + if ($this->isArrayStart($index)) { + [$array, $index] = $this->readArray($index); + $arrays[] = $array; + } + } + + return $arrays; + } + + private function isArrayStart(int $index): bool + { + $token = $this->tokens[$index]; + + if ($token->is(T_ARRAY)) { + return ($this->tokens[$index + 1]->text ?? null) === '('; + } + + if ($token->text !== '[') { + return false; + } + + $previous = $this->tokens[$index - 1] ?? null; + + // After a value, `[` indexes it rather than opening an array. + return $previous === null || ! ($previous->is([T_VARIABLE, T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_CONSTANT_ENCAPSED_STRING, T_STATIC]) + || in_array($previous->text, [')', ']', '}'], true)); + } + + /** + * @return array{PhpLiteral, int} The array and the index of its closing token. + */ + private function readArray(int $index): array + { + $open = $this->tokens[$index]->is(T_ARRAY) ? $index + 1 : $index; + $closing = $this->findClosing($open); + $first = $this->tokens[$index]; + $last = $this->tokens[$closing]; + $array = new PhpLiteral(PhpLiteral::ARRAY, $first->pos, $last->pos + strlen($last->text), $first->line, ''); + $nextKey = 0; + $cursor = $open + 1; + + while ($cursor < $closing) { + $end = $this->findEntryEnd($cursor, $closing); + $arrow = $this->findTopLevelArrow($cursor, $end); + + if ($end > $cursor) { + if ($arrow !== null) { + $key = $this->readKey($cursor, $arrow); + $value = $this->readValue($arrow + 1, $end); + } elseif ($this->tokens[$cursor]->is(T_ELLIPSIS)) { + $key = null; + $value = $this->readValue($cursor + 1, $end); + } else { + $key = $nextKey; + $value = $this->readValue($cursor, $end); + } + + $value->parent = $array; + $value->parentKey = $key; + + if ($key === null) { + $array->embedded[] = $value; + } else { + $array->entries[$key] = $value; + if (is_int($key)) { + $nextKey = max($nextKey, $key + 1); + } + } + } + + $cursor = $end + 1; + } + + return [$array, $closing]; + } + + private function readKey(int $from, int $to): int|string|null + { + $key = $this->readValue($from, $to); + + return $key->integer ?? $key->string; + } + + private function readValue(int $from, int $to): PhpLiteral + { + $first = $this->tokens[$from]; + $last = $this->tokens[$to - 1]; + $start = $first->pos; + $end = $last->pos + strlen($last->text); + $count = $to - $from; + $isScalar = ($count === 1 && ($first->is([T_LNUMBER, T_DNUMBER, T_CONSTANT_ENCAPSED_STRING]) + || ($first->is(T_STRING) && in_array(strtolower($first->text), ['true', 'false', 'null'], true)))) + || ($count === 2 && $first->text === '-' && $last->is([T_LNUMBER, T_DNUMBER])); + + if ($isScalar) { + return new PhpLiteral(PhpLiteral::SCALAR, $start, $end, $first->line, implode('', array_map( + static fn(PhpToken $token): string => $token->text, + array_slice($this->tokens, $from, $count), + ))); + } + + if ($this->isArrayStart($from)) { + [$array, $closing] = $this->readArray($from); + if ($closing === $to - 1) { + return $array; + } + } + + $expression = new PhpLiteral(PhpLiteral::OTHER, $start, $end, $first->line, ''); + $expression->embedded = $this->collectArrays($from, $to); + foreach ($expression->embedded as $array) { + $array->owner = $expression; + } + + return $expression; + } + + private function findClosing(int $open): int + { + $depth = 0; + $count = count($this->tokens); + + for ($index = $open; $index < $count; $index++) { + $depth += $this->getDepthChange($this->tokens[$index]); + if ($depth === 0) { + return $index; + } + } + + throw new InvalidArgumentException('An array literal is not closed.'); + } + + /** The index of the `,` ending an entry, or of the array's closing token. */ + private function findEntryEnd(int $from, int $closing): int + { + $depth = 0; + + for ($index = $from; $index < $closing; $index++) { + $token = $this->tokens[$index]; + if ($depth === 0 && $token->text === ',') { + return $index; + } + $depth += $this->getDepthChange($token); + } + + return $closing; + } + + private function findTopLevelArrow(int $from, int $to): ?int + { + $depth = 0; + + for ($index = $from; $index < $to; $index++) { + $token = $this->tokens[$index]; + if ($depth === 0 && $token->is(T_DOUBLE_ARROW)) { + return $index; + } + if ($depth === 0 && $token->is([T_FN, T_FUNCTION, T_MATCH])) { + return null; + } + $depth += $this->getDepthChange($token); + } + + return null; + } + + private function getDepthChange(PhpToken $token): int + { + if ($token->is([T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES, T_ATTRIBUTE]) || in_array($token->text, ['(', '[', '{'], true)) { + return 1; + } + + return in_array($token->text, [')', ']', '}'], true) ? -1 : 0; + } +} diff --git a/src/Upgrade/TwoColumnCells/ProjectMap.php b/src/Upgrade/TwoColumnCells/ProjectMap.php new file mode 100644 index 0000000..9debb73 --- /dev/null +++ b/src/Upgrade/TwoColumnCells/ProjectMap.php @@ -0,0 +1,92 @@ +.data.php`, `.event.php` + * and either a `layers/` directory or a legacy `.map.php`. + */ +final readonly class ProjectMap +{ + public const string MAPS_DIRECTORY = 'assets/Maps'; + + /** + * @param string $id The map id, such as `happyville/inn/front`. + * @param string $dataPath The map data file. + * @param string|null $eventPath The event layer, when it exists. + * @param array $layerPaths Layer files and their identities, in load order. + * @param bool $legacy Whether the map has one `.map.php` instead of `layers/`. + */ + public function __construct( + public string $id, + public string $dataPath, + public ?string $eventPath, + public array $layerPaths, + public bool $legacy, + ) { + } + + /** + * Every map in a project, by id. + * + * @return array + */ + public static function findAll(string $projectRoot): array + { + $mapsRoot = $projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, self::MAPS_DIRECTORY); + + if (! is_dir($mapsRoot)) { + return []; + } + + $maps = []; + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($mapsRoot, RecursiveDirectoryIterator::SKIP_DOTS)); + + foreach ($files as $file) { + $path = $file->getPathname(); + $directory = dirname($path); + $leaf = basename($directory); + + if (basename($path) !== $leaf . '.data.php') { + continue; + } + + $id = str_replace(DIRECTORY_SEPARATOR, '/', substr($directory, strlen($mapsRoot) + 1)); + $eventPath = $directory . DIRECTORY_SEPARATOR . $leaf . '.event.php'; + $layerDirectory = $directory . DIRECTORY_SEPARATOR . MapLayerSource::DIRECTORY; + $layerPaths = []; + $legacy = ! is_dir($layerDirectory); + + if ($legacy) { + $legacyPath = $directory . DIRECTORY_SEPARATOR . $leaf . '.map.php'; + if (is_file($legacyPath)) { + $layerPaths[$legacyPath] = ['name' => 'terrain', 'order' => 0, 'decoration' => false]; + } + } else { + $layerFiles = glob($layerDirectory . DIRECTORY_SEPARATOR . '*.php') ?: []; + sort($layerFiles); + foreach ($layerFiles as $layerPath) { + $identity = preg_match(MapLayerSource::FILENAME_PATTERN, basename($layerPath), $matches) === 1 + ? ['name' => $matches['name'], 'order' => (int) $matches['order'], 'decoration' => $matches['kind'] === 'deco'] + : null; + if ($identity !== null) { + $layerPaths[$layerPath] = $identity; + } + } + } + + $maps[$id] = new self($id, $path, is_file($eventPath) ? $eventPath : null, $layerPaths, $legacy); + } + + ksort($maps); + + return $maps; + } +} diff --git a/src/Upgrade/TwoColumnCells/SaveMigrationAppender.php b/src/Upgrade/TwoColumnCells/SaveMigrationAppender.php new file mode 100644 index 0000000..30d524e --- /dev/null +++ b/src/Upgrade/TwoColumnCells/SaveMigrationAppender.php @@ -0,0 +1,149 @@ +root(); + $versionEntry = $root->entryFor('contentVersion'); + $version = $versionEntry !== null && $versionEntry->value->kind === SourceNode::SCALAR + ? substr($source, $versionEntry->value->start, $versionEntry->value->end - $versionEntry->value->start) + : null; + + if ($version === null || preg_match('/\A[0-9]+\z/', $version) !== 1) { + throw new RuntimeException('contentVersion is not an integer literal, so the save migration cannot be appended automatically.'); + } + + $from = (int) $version; + $shortName = substr($class, (int) strrpos($class, '\\') + 1); + + if (str_contains($source, $shortName . '::class') || str_contains($source, $class . '::class')) { + return ['source' => $source, 'from' => $from - 1, 'to' => $from, 'added' => false]; + } + + $imported = $this->isImported($source, $class); + $reference = $imported || ! $this->usesName($source, $shortName) ? $shortName : '\\' . $class; + $migrations = $root->entryFor('migrations'); + $container = $migrations === null ? $root : $migrations->value; + // A manifest written on one line gets its new entry on that line too. + $inline = $container->entries !== [] && $document->lineIndentBefore($container->entries[0]->start) === null; + $entry = $inline + ? "['from' => {$from}, 'to' => " . ($from + 1) . ", 'class' => {$reference}::class]" + : "[\n 'from' => {$from},\n 'to' => " . ($from + 1) . ",\n 'class' => {$reference}::class,\n]"; + $firstMigration = $inline ? "[{$entry}]" : "[\n " . str_replace("\n", "\n ", $entry) . ",\n]"; + $edits = [[$versionEntry->value->start, $versionEntry->value->end, (string) ($from + 1)]]; + + if ($migrations === null) { + $edits[] = $document->insertEntryEdit([], count($root->entries), 'migrations', $firstMigration); + } elseif ($migrations->value->kind === SourceNode::ARRAY && $migrations->value->entries === []) { + // An empty list becomes the first migration, indented from its key's line. + $edits[] = $document->replaceValueEdit(['migrations'], $firstMigration); + } elseif ($migrations->value->kind === SourceNode::ARRAY) { + $edits[] = $document->insertEntryEdit(['migrations'], count($migrations->value->entries), null, $entry); + } else { + throw new RuntimeException('migrations is not an array literal, so the save migration cannot be appended automatically.'); + } + + $updated = $document->withEdits($edits)->source; + + if (! $imported && $reference === $shortName) { + $updated = $this->addImport($updated, $class); + } + + // The result must read back with the new step at the end of the chain. + $check = PhpArraySourceDocument::parse($updated); + $newVersion = $check->root()->entryFor('contentVersion'); + if ($newVersion === null || substr($updated, $newVersion->value->start, $newVersion->value->end - $newVersion->value->start) !== (string) ($from + 1)) { + throw new RuntimeException('The save migration could not be appended without changing other manifest data.'); + } + + return ['source' => $updated, 'from' => $from, 'to' => $from + 1, 'added' => true]; + } + + private function isImported(string $source, string $class): bool + { + return preg_match('/^use\s+\\\\?' . preg_quote($class, '/') . '\s*;/m', $source) === 1; + } + + /** Whether the short name is already taken by another import or class. */ + private function usesName(string $source, string $shortName): bool + { + return preg_match('/\b' . preg_quote($shortName, '/') . '\b/', $source) === 1; + } + + /** Imports a class after the last top-level `use`, or before the `return`. */ + private function addImport(string $source, string $class): string + { + $line = "use {$class};\n"; + $lastUseEnd = null; + $returnStart = null; + $depth = 0; + $tokens = PhpToken::tokenize($source); + $previous = null; + + foreach ($tokens as $index => $token) { + if ($token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { + continue; + } + + if (in_array($token->text, ['(', '[', '{'], true)) { + $depth++; + } elseif (in_array($token->text, [')', ']', '}'], true)) { + $depth--; + } + + $startsStatement = $previous === null || $previous->is(T_OPEN_TAG) || $previous->text === ';'; + $previous = $token; + + if ($depth === 0 && $startsStatement && $token->is(T_USE)) { + for ($end = $index; $end < count($tokens) && $tokens[$end]->text !== ';'; $end++) { + } + $lastUseEnd = $tokens[$end]->pos + 1; + } + + if ($depth === 0 && $token->is(T_RETURN)) { + $returnStart = $token->pos; + break; + } + } + + if ($lastUseEnd !== null) { + $lineEnd = strpos($source, "\n", $lastUseEnd); + $at = $lineEnd === false ? strlen($source) : $lineEnd + 1; + + return substr($source, 0, $at) . $line . substr($source, $at); + } + + if ($returnStart === null) { + throw new RuntimeException('The manifest has no top-level return.'); + } + + $at = (int) strrpos(substr($source, 0, $returnStart), "\n") + 1; + + return substr($source, 0, $at) . $line . "\n" . substr($source, $at); + } +} diff --git a/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.data.php b/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.data.php new file mode 100644 index 0000000..a6a15fc --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.data.php @@ -0,0 +1,20 @@ + 'arrival', + 'name' => 'Arrival', + 'startMap' => 'village', + 'cast' => [[ + 'id' => 'gate', + 'sprite' => ['=='], + 'x' => 3, + 'y' => 3, + 'collision' => false, + ]], + 'skip' => ['policy' => 'authored'], + 'checkpoints' => [], + 'finalizer' => [ + ['type' => 'transfer', 'map' => 'village', 'x' => 0, 'y' => 1], + ['type' => 'camera', 'operation' => 'attach'], + ], +]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.script.php b/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.script.php new file mode 100644 index 0000000..93d3195 --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.script.php @@ -0,0 +1,10 @@ + 'camera', 'operation' => 'route', 'points' => [ + ['target' => ['kind' => 'position', 'x' => 1, 'y' => 1], 'seconds' => 0.5], + ['kind' => 'position', 'x' => 4, 'y' => 3, 'seconds' => 0.5], + ]], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 2], ['y' => 3]]], + ['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'right', 'count' => 2 + 1]]], +]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Data/save-compatibility.php b/tests/fixtures/two-column-cells/expected/assets/Data/save-compatibility.php new file mode 100644 index 0000000..10021f0 --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Data/save-compatibility.php @@ -0,0 +1,20 @@ + 3, + 'migrations' => [ + [ + 'from' => 2, + 'to' => 3, + 'class' => TwoColumnCellsMigration::class, + ], + ], + 'aliases' => [ + 'maps' => [ + ['from' => 'old-village', 'to' => 'village'], + ], + ], + 'tombstones' => [], +]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Data/system.php b/tests/fixtures/two-column-cells/expected/assets/Data/system.php new file mode 100644 index 0000000..5bd3f69 --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Data/system.php @@ -0,0 +1,19 @@ + 'Two Column Cells', + 'startingPositions' => [ + 'player' => [ + 'destinationMap' => 'village', + 'spawnPoint' => [ + 'x' => 1, + 'y' => 1, + ], + 'spawnSprite' => ['v'], + ], + ], + 'battle' => [ + // Battle positions are pixels and stay as they are. + 'formation' => [['x' => 40, 'y' => 10], ['x' => 44, 'y' => 14]], + ], +]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Events/meet-elder.php b/tests/fixtures/two-column-cells/expected/assets/Events/meet-elder.php new file mode 100644 index 0000000..af0017f --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Events/meet-elder.php @@ -0,0 +1,17 @@ + ['type' => 'move_route', 'subject' => 'player', 'steps' => $steps]; + +return [ + ['type' => 'stage_actor', 'id' => 'crow', 'sprite' => 'v', 'x' => 3, 'y' => 1], + ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 4, 'y' => 2], 'seconds' => 0.5], + ['type' => 'field_animation', 'animation' => 'Sparkle', 'target' => ['kind' => 'screen_position', 'x' => 40, 'y' => 5]], + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'elder', 'waypoints' => [['x' => 1], ['x' => 3, 'y' => 3], ['y' => 1]]], + $walk([['direction' => 'right', 'count' => 2]]), + [ + 'type' => 'branch', + 'conditions' => [['type' => 'switch', 'name' => 'met-elder', 'value' => true]], + 'then' => [['type' => 'transfer', 'map' => 'cave', 'x' => 2, 'y' => 1]], + 'else' => [['type' => 'camera', 'operation' => 'focus', 'kind' => 'position', 'x' => 3, 'y' => 1]], + ], +]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.data.php b/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.data.php new file mode 100644 index 0000000..9ca6815 --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.data.php @@ -0,0 +1,7 @@ + 'Cave', + 'npcs' => [], + 'events' => [], +]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.event.php b/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.event.php new file mode 100644 index 0000000..5755cf0 --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.event.php @@ -0,0 +1,9 @@ + CollisionType::SOLID, + '|' => CollisionType::SOLID, + '~' => CollisionType::SOLID, + '.' => CollisionType::NONE, + ' ' => CollisionType::NONE, + 'o' => CollisionType::COLLECTABLE, +]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.data.php b/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.data.php new file mode 100644 index 0000000..5f974fc --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.data.php @@ -0,0 +1,7 @@ + 'Grove', + 'npcs' => [], + 'events' => [], +]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.event.php b/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.event.php new file mode 100644 index 0000000..6d3e1c4 --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.event.php @@ -0,0 +1,8 @@ +...........# +#...........# +####.######## +#...........# +#.....# +####### +ICHILOTO_MAP; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/02.props.deco.php b/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/02.props.deco.php new file mode 100644 index 0000000..c8bc96f --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/02.props.deco.php @@ -0,0 +1,12 @@ +o + + + + + +ICHILOTO_MAP; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/03.walls.map.php b/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/03.walls.map.php new file mode 100644 index 0000000..0a9bc7c --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/03.walls.map.php @@ -0,0 +1,11 @@ + 'Village', + // A region map station is a schematic position, not a field cell. + 'station' => ['x' => 3, 'y' => 1], + 'npcs' => [ + [ + 'id' => 'elder', + 'name' => 'Elder', + 'sprite' => '@', + 'x' => 2, + 'y' => 1, + 'wanderArea' => ['x' => 1, 'y' => 1, 'width' => 3, 'height' => 2], + ], + [ + 'id' => 'guard', + 'name' => 'Guard', + 'sprite' => '@', + 'x' => 4, + 'y' => 4, + ], + [ + 'id' => 'twin', + 'name' => 'Twin', + 'sprite' => '@', + 'x' => 2, + 'y' => 1, + ], + ...array_map( + static fn(int $x): array => ['id' => "post-{$x}", 'name' => 'Post', 'sprite' => '|', 'x' => $x, 'y' => 4], + [6], + ), + ], + 'triggers' => [ + [ + 'destinationMap' => 'grove', + 'trigger_area' => ['x' => 3, 'y' => 4, 'width' => 1, 'height' => 1], + 'spawn_point' => ['x' => 1, 'y' => 1], + ], + ], + 'events' => [ + 'A' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', + 'data' => [ + 'destinationMap' => 'cave', + 'spawnPoint' => [ + 'x' => 1, + 'y' => 3, + ], + 'spawnSprite' => [MovementHeading::SOUTH->value], + ], + ], + 'B' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger', + 'data' => ['loot' => 'Potion', 'quantity' => 1], + ], + 'C' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger', + 'data' => ['loot' => 'Ether', 'quantity' => 1], + ], + 'E' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => [ + 'mode' => 'action', + 'script' => [ + ['type' => 'move_player', 'x' => 3, 'y' => 1], + ['type' => 'move_route', 'subject' => 'player', 'steps' => [ + ['direction' => 'left', 'count' => 1], + ['direction' => 'up'], + ['direction' => 'right'], + ['direction' => 'left', 'faceOnly' => true], + ]], + ['type' => 'transfer', 'map' => 'village', 'x' => 2, 'y' => 1], + ], + ], + ], + ], + 'decorations' => [ + 'banner' => ['x' => 12, 'y' => 0], + ], +]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/village/village.event.php b/tests/fixtures/two-column-cells/expected/assets/Maps/village/village.event.php new file mode 100644 index 0000000..50bdf6e --- /dev/null +++ b/tests/fixtures/two-column-cells/expected/assets/Maps/village/village.event.php @@ -0,0 +1,11 @@ + 'arrival', + 'name' => 'Arrival', + 'startMap' => 'village', + 'cast' => [[ + 'id' => 'gate', + 'sprite' => ['=='], + 'x' => 7, + 'y' => 3, + 'collision' => false, + ]], + 'skip' => ['policy' => 'authored'], + 'checkpoints' => [], + 'finalizer' => [ + ['type' => 'transfer', 'map' => 'village', 'x' => 1, 'y' => 1], + ['type' => 'camera', 'operation' => 'attach'], + ], +]; diff --git a/tests/fixtures/two-column-cells/project/assets/Cutscenes/Cinematics/arrival/arrival.script.php b/tests/fixtures/two-column-cells/project/assets/Cutscenes/Cinematics/arrival/arrival.script.php new file mode 100644 index 0000000..8f83a60 --- /dev/null +++ b/tests/fixtures/two-column-cells/project/assets/Cutscenes/Cinematics/arrival/arrival.script.php @@ -0,0 +1,10 @@ + 'camera', 'operation' => 'route', 'points' => [ + ['target' => ['kind' => 'position', 'x' => 2, 'y' => 1], 'seconds' => 0.5], + ['kind' => 'position', 'x' => 8, 'y' => 3, 'seconds' => 0.5], + ]], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 5], ['y' => 3]]], + ['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'right', 'count' => 2 + 1]]], +]; diff --git a/tests/fixtures/two-column-cells/project/assets/Data/save-compatibility.php b/tests/fixtures/two-column-cells/project/assets/Data/save-compatibility.php new file mode 100644 index 0000000..0492f9b --- /dev/null +++ b/tests/fixtures/two-column-cells/project/assets/Data/save-compatibility.php @@ -0,0 +1,12 @@ + 2, + 'migrations' => [], + 'aliases' => [ + 'maps' => [ + ['from' => 'old-village', 'to' => 'village'], + ], + ], + 'tombstones' => [], +]; diff --git a/tests/fixtures/two-column-cells/project/assets/Data/system.php b/tests/fixtures/two-column-cells/project/assets/Data/system.php new file mode 100644 index 0000000..383e9c1 --- /dev/null +++ b/tests/fixtures/two-column-cells/project/assets/Data/system.php @@ -0,0 +1,19 @@ + 'Two Column Cells', + 'startingPositions' => [ + 'player' => [ + 'destinationMap' => 'village', + 'spawnPoint' => [ + 'x' => 3, + 'y' => 1, + ], + 'spawnSprite' => ['v'], + ], + ], + 'battle' => [ + // Battle positions are pixels and stay as they are. + 'formation' => [['x' => 40, 'y' => 10], ['x' => 44, 'y' => 14]], + ], +]; diff --git a/tests/fixtures/two-column-cells/project/assets/Events/meet-elder.php b/tests/fixtures/two-column-cells/project/assets/Events/meet-elder.php new file mode 100644 index 0000000..829200c --- /dev/null +++ b/tests/fixtures/two-column-cells/project/assets/Events/meet-elder.php @@ -0,0 +1,17 @@ + ['type' => 'move_route', 'subject' => 'player', 'steps' => $steps]; + +return [ + ['type' => 'stage_actor', 'id' => 'crow', 'sprite' => 'v', 'x' => 7, 'y' => 1], + ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 9, 'y' => 2], 'seconds' => 0.5], + ['type' => 'field_animation', 'animation' => 'Sparkle', 'target' => ['kind' => 'screen_position', 'x' => 40, 'y' => 5]], + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'elder', 'waypoints' => [['x' => 3], ['x' => 7, 'y' => 3], ['y' => 1]]], + $walk([['direction' => 'right', 'count' => 4]]), + [ + 'type' => 'branch', + 'conditions' => [['type' => 'switch', 'name' => 'met-elder', 'value' => true]], + 'then' => [['type' => 'transfer', 'map' => 'cave', 'x' => 5, 'y' => 1]], + 'else' => [['type' => 'camera', 'operation' => 'focus', 'kind' => 'position', 'x' => 6, 'y' => 1]], + ], +]; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.data.php b/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.data.php new file mode 100644 index 0000000..9ca6815 --- /dev/null +++ b/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.data.php @@ -0,0 +1,7 @@ + 'Cave', + 'npcs' => [], + 'events' => [], +]; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.event.php b/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.event.php new file mode 100644 index 0000000..5755cf0 --- /dev/null +++ b/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.event.php @@ -0,0 +1,9 @@ + CollisionType::SOLID, + '|' => CollisionType::SOLID, + '~' => CollisionType::SOLID, + '.' => CollisionType::NONE, + ' ' => CollisionType::NONE, + 'o' => CollisionType::COLLECTABLE, +]; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.data.php b/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.data.php new file mode 100644 index 0000000..5f974fc --- /dev/null +++ b/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.data.php @@ -0,0 +1,7 @@ + 'Grove', + 'npcs' => [], + 'events' => [], +]; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.event.php b/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.event.php new file mode 100644 index 0000000..6d3e1c4 --- /dev/null +++ b/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.event.php @@ -0,0 +1,8 @@ +...........# +#...........# +####.######## +#...........# +#.....# +####### +ICHILOTO_MAP; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/02.props.deco.php b/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/02.props.deco.php new file mode 100644 index 0000000..dce28bb --- /dev/null +++ b/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/02.props.deco.php @@ -0,0 +1,12 @@ +o + + + + + +ICHILOTO_MAP; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/03.walls.map.php b/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/03.walls.map.php new file mode 100644 index 0000000..96ff00d --- /dev/null +++ b/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/03.walls.map.php @@ -0,0 +1,11 @@ + 'Village', + // A region map station is a schematic position, not a field cell. + 'station' => ['x' => 3, 'y' => 1], + 'tiles2d' => [ + 'asset' => 'Graphics/Tilesets/Village.png', + 'symbols' => [ + '#' => ['x' => 16, 'y' => 0, 'width' => 16, 'height' => 32], + ], + ], + 'npcs' => [ + [ + 'id' => 'elder', + 'name' => 'Elder', + 'sprite' => '@', + 'x' => 5, + 'y' => 1, + 'wanderArea' => ['x' => 3, 'y' => 1, 'width' => 4, 'height' => 2], + ], + [ + 'id' => 'guard', + 'name' => 'Guard', + 'sprite' => '@', + 'x' => 9, + 'y' => 4, + ], + [ + 'id' => 'twin', + 'name' => 'Twin', + 'sprite' => '@', + 'x' => 4, + 'y' => 1, + ], + ...array_map( + static fn(int $x): array => ['id' => "post-{$x}", 'name' => 'Post', 'sprite' => '|', 'x' => $x, 'y' => 4], + [6], + ), + ], + 'triggers' => [ + [ + 'destinationMap' => 'grove', + 'trigger_area' => ['x' => 7, 'y' => 4, 'width' => 1, 'height' => 1], + 'spawn_point' => ['x' => 3, 'y' => 1], + ], + ], + 'events' => [ + 'A' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', + 'data' => [ + 'destinationMap' => 'cave', + 'spawnPoint' => [ + 'x' => 2, + 'y' => 3, + ], + 'spawnSprite' => [MovementHeading::SOUTH->value], + ], + ], + 'B' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger', + 'data' => ['loot' => 'Potion', 'quantity' => 1], + ], + 'C' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger', + 'data' => ['loot' => 'Ether', 'quantity' => 1], + ], + 'E' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => [ + 'mode' => 'action', + 'script' => [ + ['type' => 'move_player', 'x' => 7, 'y' => 1], + ['type' => 'move_route', 'subject' => 'player', 'steps' => [ + ['direction' => 'left', 'count' => 3], + ['direction' => 'up'], + ['direction' => 'right'], + ['direction' => 'left', 'faceOnly' => true], + ]], + ['type' => 'transfer', 'map' => 'village', 'x' => 5, 'y' => 1], + ], + ], + ], + ], + 'decorations' => [ + 'banner' => ['x' => 12, 'y' => 0], + ], +]; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/village/village.event.php b/tests/fixtures/two-column-cells/project/assets/Maps/village/village.event.php new file mode 100644 index 0000000..24d61b5 --- /dev/null +++ b/tests/fixtures/two-column-cells/project/assets/Maps/village/village.event.php @@ -0,0 +1,11 @@ + 1, 'migrations' => [['from' => 0, 'to' => 1, 'class' => TwoColumnCellsMigration::class]]] + + SaveCompatibilityMetadata::baseline(); + + if ($manifest != $expectedManifest || ! is_file($legacyRoot . '/ichiloto-upgrade-report.md')) { + failUpgradeTest('The upgrade did not create the canonical compatibility manifest and chain the format 2 migration.'); } load_engine_autoloader($legacyRoot); $runtimeManifest = SaveCompatibilityManifest::fromProjectRoot($legacyRoot); - if ($runtimeManifest->projectId !== 'moon-studio/legacy-moon' || $runtimeManifest->contentVersion !== 0) { + if ($runtimeManifest->projectId !== 'moon-studio/legacy-moon' || $runtimeManifest->contentVersion !== 1) { failUpgradeTest('The Engine did not accept the upgraded save compatibility contract.'); } @@ -183,12 +189,15 @@ function failUpgradeTest(string $message): never } $preserved = runUpgradeCommand($consoleBin, $consoleRoot, ['--directory', $preservedRoot, '--yes']); + $preservedManifest = require $preservedRoot . '/assets/Data/save-compatibility.php'; $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: ' . $preserved['output']); + || ($preservedManifest['custom'] ?? null) !== true + || ($preservedManifest['contentVersion'] ?? null) !== 8 + || ($preservedManifest['migrations'] ?? null) !== [['from' => 7, 'to' => 8, 'class' => TwoColumnCellsMigration::class]]) { + failUpgradeTest('Existing save identity or compatibility metadata was overwritten instead of extended: ' . $preserved['output']); } $invalidRoot = $temporaryRoot . '/invalid-id'; @@ -215,4 +224,4 @@ function failUpgradeTest(string $message): never exit(1); } -fwrite(STDOUT, "PASS: legacy projects gain stable, idempotent save metadata without overwriting existing contracts through the format chain.\n"); +fwrite(STDOUT, "PASS: legacy projects gain stable, idempotent save metadata without overwriting existing contracts, then continue through the format chain.\n"); diff --git a/tests/two-column-cells-upgrade.php b/tests/two-column-cells-upgrade.php new file mode 100644 index 0000000..d998f55 --- /dev/null +++ b/tests/two-column-cells-upgrade.php @@ -0,0 +1,263 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $projectRoot, + ); + + if (! is_resource($process)) { + failTwoColumnUpgrade('Unable to start the upgrade command.'); + } + + 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' => trim($output)]; +} + +function runFixtureGit(string $directory, string ...$arguments): void +{ + $command = array_merge(['git', '-C', $directory, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid'], $arguments); + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + + if (! is_resource($process)) { + failTwoColumnUpgrade('Git is required for this test.'); + } + + $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + if (proc_close($process) !== 0) { + failTwoColumnUpgrade('git ' . implode(' ', $arguments) . ' failed: ' . $output); + } +} + +function copyTwoColumnTree(string $source, string $destination): void +{ + if (! is_dir($destination) && ! mkdir($destination, 0777, true) && ! is_dir($destination)) { + failTwoColumnUpgrade("Unable to create {$destination}."); + } + + foreach (scandir($source) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $from = $source . DIRECTORY_SEPARATOR . $entry; + $to = $destination . DIRECTORY_SEPARATOR . $entry; + is_dir($from) ? copyTwoColumnTree($from, $to) : copy($from, $to); + } +} + +function removeTwoColumnTree(string $directory): void +{ + if (! is_dir($directory)) { + return; + } + + foreach (scandir($directory) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + is_dir($path) && ! is_link($path) ? removeTwoColumnTree($path) : unlink($path); + } + + rmdir($directory); +} + +/** @return array File contents by relative path, without Git's own files. */ +function readTwoColumnTree(string $root, string $prefix = ''): array +{ + $files = []; + + foreach (scandir($root . $prefix) ?: [] as $entry) { + if ($entry === '.' || $entry === '..' || ($prefix === '' && $entry === '.git')) { + continue; + } + + $relative = $prefix . '/' . $entry; + $path = $root . $relative; + $files += is_dir($path) ? readTwoColumnTree($root, $relative) : [ltrim($relative, '/') => (string) file_get_contents($path)]; + } + + ksort($files); + + return $files; +} + +/** @param array $expected @param array $actual */ +function assertSameTree(array $expected, array $actual, string $context): void +{ + if (array_keys($expected) !== array_keys($actual)) { + failTwoColumnUpgrade(sprintf( + '%s: the files differ. Missing: %s. Unexpected: %s.', + $context, + implode(', ', array_diff(array_keys($expected), array_keys($actual))) ?: 'none', + implode(', ', array_diff(array_keys($actual), array_keys($expected))) ?: 'none', + )); + } + + foreach ($expected as $path => $contents) { + if ($actual[$path] !== $contents) { + failTwoColumnUpgrade("{$context}: {$path} differs from the expected output."); + } + } +} + +function createCommittedProject(string $fixtureRoot, string $projectRoot): void +{ + copyTwoColumnTree($fixtureRoot . '/project', $projectRoot); + runFixtureGit($projectRoot, 'init', '-q'); + runFixtureGit($projectRoot, 'add', '-A'); + runFixtureGit($projectRoot, 'commit', '-q', '-m', 'Format 1 fixture'); +} + +try { + $project = $temporaryRoot . '/project'; + createCommittedProject($fixtureRoot, $project); + $original = readTwoColumnTree($project); + $expected = readTwoColumnTree($fixtureRoot . '/expected'); + + $dryRun = runTwoColumnUpgrade($consoleBin, $project, ['--dry-run']); + foreach ([ + 'This project is at format 1; Ichiloto reads format 2. The upgrade will:', + 'Regroup 3 maps into two-column cells: 5 of 8 grid files change (29 rows padded to an even width, 1 two-column glyph moved right).', + 'Halve 22 field x coordinates in 5 files.', + 'Halve 3 horizontal move route steps (each is listed for review).', + 'Remove retired tiles2d crops from 1 map.', + 'Add save migration 2 to 3 (TwoColumnCellsMigration)', + 'Report 2 items that block loading and 18 for review or hand conversion.', + 'Dry run only; no files were changed.', + ] as $line) { + if ($dryRun['exitCode'] !== 0 || ! str_contains($dryRun['output'], $line)) { + failTwoColumnUpgrade("The dry run did not list \"{$line}\": {$dryRun['output']}"); + } + } + assertSameTree($original, readTwoColumnTree($project), 'The dry run'); + + $unconfirmed = runTwoColumnUpgrade($consoleBin, $project, []); + if ($unconfirmed['exitCode'] === 0 || ! str_contains($unconfirmed['output'], 'Pass --yes')) { + failTwoColumnUpgrade('A non-interactive upgrade ran without --yes: ' . $unconfirmed['output']); + } + assertSameTree($original, readTwoColumnTree($project), 'The unconfirmed upgrade'); + + file_put_contents($project . '/notes.txt', "Unfinished work.\n"); + $dirty = runTwoColumnUpgrade($consoleBin, $project, ['--yes']); + if ($dirty['exitCode'] === 0 || ! str_contains($dirty['output'], '1 uncommitted change')) { + failTwoColumnUpgrade('The upgrade ran over uncommitted changes: ' . $dirty['output']); + } + unlink($project . '/notes.txt'); + assertSameTree($original, readTwoColumnTree($project), 'The refused upgrade'); + + $upgrade = runTwoColumnUpgrade($consoleBin, $project, ['--yes']); + if ($upgrade['exitCode'] !== 0) { + failTwoColumnUpgrade('The upgrade failed: ' . $upgrade['output']); + } + assertSameTree($expected, readTwoColumnTree($project), 'The upgrade'); + + foreach ([ + '✓ Upgraded the project to format 2.', + 'Maps that will not load until fixed:', + 'assets/Maps/village/village.event.php row 2, cell 1 holds two different markers, B and C.', + 'grove: Event map assets/Maps/grove/grove.event.php row 1 must be 4 cells wide.', + 'village: 6 cells became solid; reachability was compared from 3 entry points.', + 'village: event E can no longer be reached from the map\'s entry points.', + 'village: NPC at assets/Maps/village/village.data.php:28 npcs[1] can no longer be reached from the map\'s entry points.', + 'village: trigger at assets/Maps/village/village.data.php:46 triggers[0].trigger_area can no longer be reached', + 'village: 7 walkable cells around cell (1, 4) can no longer be reached; likely closed by cell (2, 3), ".#", which became solid.', + 'cave: the arrival at assets/Maps/village/village.data.php:56 events.A.data.spawnPoint no longer connects to 1 other entry point', + 'Follow-up list written to ' . realpath($project) . '/ichiloto-upgrade-report.md', + ] as $line) { + if (! str_contains($upgrade['output'], $line)) { + failTwoColumnUpgrade("The upgrade did not print \"{$line}\": {$upgrade['output']}"); + } + } + + // The engine reads what the upgrade wrote. + $village = MapLayerSource::loadFromDirectory($project . '/assets/Maps/village', 'village'); + if (count($village->getComposedGrid()[0]) !== 7 || count($village->getComposedGrid()[5]) !== 4) { + failTwoColumnUpgrade('The engine did not read the converted village as rows of two-column cells.'); + } + $manifest = SaveCompatibilityManifest::fromArray('fixture/two-column-cells', require $project . '/assets/Data/save-compatibility.php'); + if ($manifest->contentVersion !== 3 || $manifest->migrationFrom(2) !== TwoColumnCellsMigration::class) { + failTwoColumnUpgrade('The engine did not accept the appended save migration.'); + } + + $again = runTwoColumnUpgrade($consoleBin, $project, ['--yes']); + if ($again['exitCode'] !== 0 || ! str_contains($again['output'], 'No upgrade is needed; the project is already at format 2.')) { + failTwoColumnUpgrade('An up-to-date project was not reported as such: ' . $again['output']); + } + assertSameTree($expected, readTwoColumnTree($project), 'The repeated upgrade'); + + $allowed = $temporaryRoot . '/allowed'; + createCommittedProject($fixtureRoot, $allowed); + file_put_contents($allowed . '/notes.txt', "Unfinished work.\n"); + $allowedRun = runTwoColumnUpgrade($consoleBin, $allowed, ['--yes', '--allow-dirty']); + if ($allowedRun['exitCode'] !== 0) { + failTwoColumnUpgrade('--allow-dirty did not permit the upgrade: ' . $allowedRun['output']); + } + assertSameTree($expected + ['notes.txt' => "Unfinished work.\n"], readTwoColumnTree($allowed), 'The allowed dirty upgrade'); + + $newer = $temporaryRoot . '/newer'; + copyTwoColumnTree($fixtureRoot . '/project', $newer); + file_put_contents($newer . '/ichiloto.json', "{\n \"id\": \"fixture/newer\",\n \"format\": 99\n}\n"); + $newerRun = runTwoColumnUpgrade($consoleBin, $newer, ['--yes']); + if ($newerRun['exitCode'] === 0 || ! str_contains($newerRun['output'], 'newer than this Ichiloto reads')) { + failTwoColumnUpgrade('A project from a newer format was not refused: ' . $newerRun['output']); + } +} catch (TwoColumnUpgradeFailure $failure) { + $testFailure = $failure->getMessage(); +} finally { + removeTwoColumnTree($temporaryRoot); +} + +if (isset($testFailure)) { + fwrite(STDERR, "FAIL: {$testFailure}\n"); + exit(1); +} + +fwrite(STDOUT, "PASS: upgrade converts a project to two-column cells exactly, reports what needs a person, and protects uncommitted work.\n"); From 8d3c9652be62c9d000ec1578e2ebca3f6d416257 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:14:01 +0200 Subject: [PATCH 09/32] feat(console): scaffold format 2 projects with two-column cells New projects record the current project format. The starter map and the generate:map default are whole two-column cells with `##` walls, their event layers are rows of blank cells, and the new game starts in cell 2, the cell holding the old column 4. MapScaffolder refuses a row that ends halfway through a cell before creating anything. The startup test upgrades each copy of the released 0.5.0 project (and the optional EpicQuest copy) before starting it, as an author would; the released fixture itself is unchanged. --- src/Support/MapScaffolder.php | 29 ++++++++++++++++------- src/Support/NewProjectScaffolder.php | 30 +++++++++--------------- tests/generate-map.php | 22 +++++++++++++---- tests/new-project-save-compatibility.php | 19 +++++++++++++++ tests/project-startup-identity.php | 28 +++++++++++++++++++++- 5 files changed, 94 insertions(+), 34 deletions(-) diff --git a/src/Support/MapScaffolder.php b/src/Support/MapScaffolder.php index acd28a3..3977aac 100644 --- a/src/Support/MapScaffolder.php +++ b/src/Support/MapScaffolder.php @@ -4,10 +4,15 @@ namespace Ichiloto\Console\Support; +use Ichiloto\Engine\Field\MapCell; +use InvalidArgumentException; use RuntimeException; use Throwable; -/** Writes the three files that make up an Engine 0.5 map. */ +/** + * Writes the three files that make up a map: its data, one terrain layer of + * two-column cells, and an event layer of blank cells matching it. + */ final class MapScaffolder { /** @@ -38,15 +43,21 @@ public function write(string $directory, array $data, ?array $tileRows = null, b } } + $tileRows ??= self::defaultTileRows(); + $eventRows = []; + + foreach ($tileRows as $index => $row) { + try { + $eventRows[] = implode('', MapCell::getBlankRow(count(MapCell::parseRow($row, "Map row {$index}")))); + } catch (InvalidArgumentException $error) { + throw new RuntimeException($error->getMessage(), previous: $error); + } + } + if (! is_dir($directory) && ! mkdir($directory, 0755, true) && ! is_dir($directory)) { throw new RuntimeException("Could not create directory: {$directory}"); } - $tileRows ??= self::defaultTileRows(); - $eventRows = array_map( - static fn(string $row): string => str_repeat(' ', mb_strlen($row)), - $tileRows, - ); $payloads = [ 'data' => " self::renderLayer('ICHILOTO_MAP', $tileRows), @@ -87,12 +98,12 @@ public function write(string $directory, array $data, ?array $tileRows = null, b /** @return string[] */ private static function defaultTileRows(): array { - $width = 48; - $border = str_repeat('#', $width); + $cells = 24; + $border = str_repeat('##', $cells); return [ $border, - ...array_fill(0, 16, '#' . str_repeat(' ', $width - 2) . '#'), + ...array_fill(0, 16, '##' . str_repeat(MapCell::BLANK, $cells - 2) . '##'), $border, ]; } diff --git a/src/Support/NewProjectScaffolder.php b/src/Support/NewProjectScaffolder.php index 302be01..65ee8aa 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 @@ -203,25 +204,14 @@ private function writeStarterMap(string $mapDirectory): void 'events' => [], ]; + // Every cell is two terminal columns, so walls are written as `##`. + $wall = '##' . str_repeat(' ', 44) . '##'; $tileRows = [ - '################################################', - '# #', - '# #', - '# #', - '# #', - '# #', - '# #', - '# #', - '# ? #', - '# #', - '# #', - '# #', - '# #', - '# #', - '# #', - '# #', - '# #', - '################################################', + str_repeat('#', 48), + ...array_fill(0, 7, $wall), + '##' . str_repeat(' ', 22) . '? ' . str_repeat(' ', 20) . '##', + ...array_fill(0, 8, $wall), + str_repeat('#', 48), ]; new MapScaffolder()->write($mapDirectory, $mapData, $tileRows); } @@ -247,6 +237,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', @@ -523,8 +514,9 @@ private function renderSystemData(string $title, string $heroIdentity, string $b 'startingPositions' => [ 'player' => [ 'destinationMap' => self::STARTING_MAP_ID, + // A map cell is two terminal columns: cell 2 is columns 4 and 5. 'spawnPoint' => [ - 'x' => 4, + 'x' => 2, 'y' => 4, ], 'spawnSprite' => [ diff --git a/tests/generate-map.php b/tests/generate-map.php index 3d16b54..88cedf4 100644 --- a/tests/generate-map.php +++ b/tests/generate-map.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Ichiloto\Console\Commands\GenerateMapCommand; +use Ichiloto\Engine\Field\MapCell; +use Ichiloto\Engine\Field\MapLayer; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -83,16 +85,26 @@ function failMapTest(string $message): never failMapTest('generate:map layers must return strings.'); } - $tileRows = explode("\n", $tiles); - $eventRows = explode("\n", $events); + // Both layers are rows of whole two-column cells, and the event cells are blank. + $tileRows = MapLayer::parseGrid($tiles); + $eventRows = MapLayer::parseGrid($events); if (count($tileRows) !== count($eventRows)) { failMapTest('The generated event layer does not match the map height.'); } foreach ($tileRows as $index => $tileRow) { - if (mb_strlen($tileRow) !== mb_strlen($eventRows[$index])) { - failMapTest('The generated event layer does not match the map width.'); + if (count($tileRow) !== count($eventRows[$index]) || $eventRows[$index] !== MapCell::getBlankRow(count($tileRow))) { + failMapTest('The generated event layer does not match the map width in blank cells.'); + } + } + + try { + new Ichiloto\Console\Support\MapScaffolder()->write($mapsRoot . '/half-cell', ['name' => 'Half Cell'], ['###']); + failMapTest('A map row ending halfway through a cell was written.'); + } catch (RuntimeException $exception) { + if (! str_contains($exception->getMessage(), 'halfway through a cell') || is_dir($mapsRoot . '/half-cell')) { + failMapTest('A map row ending halfway through a cell was not refused before writing: ' . $exception->getMessage()); } } @@ -114,7 +126,7 @@ 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"); + fwrite(STDOUT, "PASS: generate:map writes the complete map layout in two-column cells.\n"); } catch (Throwable $throwable) { fwrite(STDERR, "FAIL: {$throwable->getMessage()}\n"); exit(1); diff --git a/tests/new-project-save-compatibility.php b/tests/new-project-save-compatibility.php index 7b551ed..b533e45 100644 --- a/tests/new-project-save-compatibility.php +++ b/tests/new-project-save-compatibility.php @@ -3,6 +3,10 @@ declare(strict_types=1); use Ichiloto\Console\Support\NewProjectScaffolder; +use Ichiloto\Engine\Core\ProjectFormat; +use Ichiloto\Engine\Field\MapCell; +use Ichiloto\Engine\Field\MapGridSource; +use Ichiloto\Engine\Field\MapLayer; require dirname(__DIR__) . '/vendor/autoload.php'; @@ -92,6 +96,21 @@ function validateScaffoldedProject(string $projectRoot, array $options = []): ar 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.'); + } + + $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 !== MapCell::BLANK) !== [] + || ($terrain[$spawn['y'] ?? -1][$spawn['x'] ?? -1] ?? null) !== MapCell::BLANK) { + failScaffolderTest('The starter map is not whole two-column cells with a blank event layer and an open start cell.'); + } + if (($composer['name'] ?? null) !== $project['id']) { failScaffolderTest('The generated Composer package and project save identities differ.'); } diff --git a/tests/project-startup-identity.php b/tests/project-startup-identity.php index 50ee90f..32a85aa 100644 --- a/tests/project-startup-identity.php +++ b/tests/project-startup-identity.php @@ -179,6 +179,27 @@ function runValidationChild(string $projectRoot, array $options = []): array ]; } +/** 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)) { @@ -315,9 +336,11 @@ function assertSilentProject(string $projectRoot): void 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. + // 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; @@ -366,6 +389,7 @@ function assertSilentProject(string $projectRoot): void 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); @@ -411,6 +435,7 @@ function assertSilentProject(string $projectRoot): void 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); @@ -441,6 +466,7 @@ function assertSilentProject(string $projectRoot): void 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); From 1cc6295fc2ce3c59e6643c1be22947566565316e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:14:38 +0200 Subject: [PATCH 10/32] feat(console): stop play and validate on an outdated project format Both commands ask the engine (ProjectFormat::assertSupported) whether it reads the project's recorded format and, when it does not, print the engine's own explanation, which points to `ichiloto upgrade`, and exit before launching the game or reading its data. Play fixtures in the renderer tests now record the current format. This narrows both commands: play no longer launches, and validate no longer checks, a project whose format differs from the engine's until it is upgraded. --- composer.json | 1 + src/Commands/PlayCommand.php | 10 ++++ src/Commands/ValidateCommand.php | 13 +++++ src/Support/ProjectFormatCheck.php | 39 ++++++++++++++ tests/project-format-refusal.php | 86 ++++++++++++++++++++++++++++++ tests/renderer-preparation.php | 2 +- tests/renderer-selection.php | 5 +- 7 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/Support/ProjectFormatCheck.php create mode 100644 tests/project-format-refusal.php diff --git a/composer.json b/composer.json index 3f2ebc8..9c58f97 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "@php tests/validate-interactive-migration.php", "@php tests/legacy-project-upgrade.php", "@php tests/two-column-cells-upgrade.php", + "@php tests/project-format-refusal.php", "@php tests/validate-working-directory.php", "@php tests/battle-report.php" ] diff --git a/src/Commands/PlayCommand.php b/src/Commands/PlayCommand.php index 2acd775..933c6c3 100644 --- a/src/Commands/PlayCommand.php +++ b/src/Commands/PlayCommand.php @@ -9,6 +9,7 @@ use Ichiloto\Console\Renderer\RendererSelector; use Ichiloto\Console\Support\GameLaunchCommandBuilder; use Ichiloto\Console\Support\GameProcessLauncher; +use Ichiloto\Console\Support\ProjectFormatCheck; use Ichiloto\Console\Support\TerminalInteractivity; use Ichiloto\Console\Support\SourceRendererUpdateChecker; use Ichiloto\Console\Support\SourceRendererUpdater; @@ -106,6 +107,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'); diff --git a/src/Commands/ValidateCommand.php b/src/Commands/ValidateCommand.php index bf8c020..0ecc543 100644 --- a/src/Commands/ValidateCommand.php +++ b/src/Commands/ValidateCommand.php @@ -2,6 +2,7 @@ namespace Ichiloto\Console\Commands; +use Ichiloto\Console\Support\ProjectFormatCheck; use Ichiloto\Editor\Actors\ActorIdentityMigration; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\ActorReferenceValidator; @@ -10,6 +11,7 @@ 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; @@ -42,6 +44,17 @@ public function execute(InputInterface $input, OutputInterface $output): int $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); 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/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/renderer-preparation.php b/tests/renderer-preparation.php index 131606d..442df54 100644 --- a/tests/renderer-preparation.php +++ b/tests/renderer-preparation.php @@ -50,7 +50,7 @@ function expectUpdateFailure(callable $action, string $message): void 'sources' => ['gpui' => ['directory' => '../../../native', 'builder' => 'builder.php']]], JSON_THROW_ON_ERROR)); file_put_contents($engine . '/src/Core/Game.php', ' 'main.php'])); + file_put_contents($project . '/ichiloto.json', json_encode(['format' => Ichiloto\Engine\Core\ProjectFormat::CURRENT, 'main' => 'main.php'])); file_put_contents($project . '/main.php', ' Ichiloto\Engine\Core\ProjectFormat::CURRENT, 'main' => 'app/game.php', 'debug' => ['enabled' => false, 'show' => false], ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL; @@ -470,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; @@ -684,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; @@ -747,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', From a329aa12f3c647d07f63f3a15ba49ec31190b228 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 26 Sep 2026 19:14:38 +0200 Subject: [PATCH 11/32] docs(console): document the upgrade format chain and its report --- README.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b769e95..df6a48b 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 map scaffolding in two-column cells - `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) @@ -200,19 +200,71 @@ 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 steps are: + +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. +2. **Format 2: square map cells.** A map cell is two terminal columns. Every + map layer and event layer is regrouped without evaluating its PHP: a row + of odd width gains a trailing space, and a two-column glyph that would + start halfway through a cell gets a space before it. Every field x + coordinate is halved, rounding down, in map data (NPCs, wander areas, + triggers, event spawn points and scripts), common events, cinematics and + the new game start; only the integer literals change. Horizontal move + route step counts are halved too. Retired `tiles2d` crops are removed, + and the save compatibility chain gains `TwoColumnCellsMigration`, so saved + games reopen in the cell that holds their column. + +The format 2 report lists, per map and file: event cells holding two +different markers and layers that no longer line up (the map will not load +until they are fixed); what the player can no longer reach; NPCs, events and +spawn points now on a solid cell; NPCs now sharing a cell; every halved move +route step, whose exact length depends on where it starts; and any coordinate +the upgrade could not identify or that is not a literal, which it leaves for +a person rather than guessing. Region map stations, screen positions and +battle positions are not field cells and stay as they are. + +Reachability is compared rather than listing every cell that became solid. +For each map, walkable ground is flood-filled from its entry points (spawn +points and transfers that arrive on it, and the new game start) over the old +one-column collision and over the new per-cell collision, walkable meaning +what the engine lets the player step onto. The report names event areas, +NPCs and transfer triggers that were reachable before and are not after +(something counts as reached from the ground beside it), arrivals that no +longer connect to each other, and each walkable region that was cut off, with +a representative cell and the newly solid cell that most likely closed it, +such as the pair that swallowed a one-column doorway. A one-line count of +cells that became solid per map is kept for context. Re-proportioning rooms +and furniture for square cells is the author's work after the conversion. ## FIGlet Generation From c0a62cd163b9206e2f3473408a4c12f0737041d5 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 27 Sep 2026 10:11:07 +0200 Subject: [PATCH 12/32] revert(console)!: withdraw the two-column cell upgrade step Two-column map cells changed how the terminal plays, so project format 2 is withdrawn. Reverted: 18beccf (TwoColumnCellsStep, src/Upgrade/TwoColumnCells, its fixtures and tests) and the map content of 8d3c965. Removed: the format 2 upgrade step and its report sections, the two-column starter map and generate:map default, blank-cell event layers and MapScaffolder's refusal of a row ending halfway through a cell. Kept: the numbered format chain with SaveMetadataStep as format 1, play and validate refusing a project whose format differs from the engine's, and new projects recording the engine's current format (1). Scaffolded maps are 48 one-column cells wide with a spawn at x 4 again. BREAKING CHANGE: ichiloto upgrade no longer converts to two-column cells, and a project recording format 2 is refused as newer than the engine. --- README.md | 36 +- composer.json | 1 - src/Support/MapScaffolder.php | 29 +- src/Support/NewProjectScaffolder.php | 28 +- src/Upgrade/ProjectUpgradeChain.php | 5 +- src/Upgrade/Steps/TwoColumnCellsStep.php | 660 ------------------ .../FieldCoordinateConverter.php | 373 ---------- .../TwoColumnCells/GridSourceConverter.php | 134 ---- .../TwoColumnCells/MapCollisionComparison.php | 137 ---- .../MapReachabilityComparison.php | 233 ------- .../TwoColumnCells/MapRowConverter.php | 110 --- src/Upgrade/TwoColumnCells/PhpLiteral.php | 168 ----- .../TwoColumnCells/PhpLiteralScanner.php | 230 ------ src/Upgrade/TwoColumnCells/ProjectMap.php | 92 --- .../TwoColumnCells/SaveMigrationAppender.php | 149 ---- .../Cinematics/arrival/arrival.data.php | 20 - .../Cinematics/arrival/arrival.script.php | 10 - .../assets/Data/save-compatibility.php | 20 - .../expected/assets/Data/system.php | 19 - .../expected/assets/Events/meet-elder.php | 17 - .../expected/assets/Maps/cave/cave.data.php | 7 - .../expected/assets/Maps/cave/cave.event.php | 9 - .../expected/assets/Maps/cave/cave.map.php | 9 - .../expected/assets/Maps/collisions.php | 12 - .../expected/assets/Maps/grove/grove.data.php | 7 - .../assets/Maps/grove/grove.event.php | 8 - .../expected/assets/Maps/grove/grove.map.php | 8 - .../Maps/village/layers/01.terrain.map.php | 11 - .../Maps/village/layers/02.props.deco.php | 12 - .../Maps/village/layers/03.walls.map.php | 11 - .../assets/Maps/village/village.data.php | 84 --- .../assets/Maps/village/village.event.php | 11 - .../expected/ichiloto-upgrade-report.md | 69 -- .../two-column-cells/expected/ichiloto.json | 8 - .../Cinematics/arrival/arrival.data.php | 20 - .../Cinematics/arrival/arrival.script.php | 10 - .../assets/Data/save-compatibility.php | 12 - .../project/assets/Data/system.php | 19 - .../project/assets/Events/meet-elder.php | 17 - .../project/assets/Maps/cave/cave.data.php | 7 - .../project/assets/Maps/cave/cave.event.php | 9 - .../project/assets/Maps/cave/cave.map.php | 9 - .../project/assets/Maps/collisions.php | 12 - .../project/assets/Maps/grove/grove.data.php | 7 - .../project/assets/Maps/grove/grove.event.php | 8 - .../project/assets/Maps/grove/grove.map.php | 8 - .../Maps/village/layers/01.terrain.map.php | 11 - .../Maps/village/layers/02.props.deco.php | 12 - .../Maps/village/layers/03.walls.map.php | 11 - .../assets/Maps/village/village.data.php | 90 --- .../assets/Maps/village/village.event.php | 11 - .../two-column-cells/project/ichiloto.json | 8 - tests/generate-map.php | 22 +- tests/legacy-project-upgrade.php | 23 +- tests/new-project-save-compatibility.php | 9 +- tests/two-column-cells-upgrade.php | 263 ------- 56 files changed, 48 insertions(+), 3287 deletions(-) delete mode 100644 src/Upgrade/Steps/TwoColumnCellsStep.php delete mode 100644 src/Upgrade/TwoColumnCells/FieldCoordinateConverter.php delete mode 100644 src/Upgrade/TwoColumnCells/GridSourceConverter.php delete mode 100644 src/Upgrade/TwoColumnCells/MapCollisionComparison.php delete mode 100644 src/Upgrade/TwoColumnCells/MapReachabilityComparison.php delete mode 100644 src/Upgrade/TwoColumnCells/MapRowConverter.php delete mode 100644 src/Upgrade/TwoColumnCells/PhpLiteral.php delete mode 100644 src/Upgrade/TwoColumnCells/PhpLiteralScanner.php delete mode 100644 src/Upgrade/TwoColumnCells/ProjectMap.php delete mode 100644 src/Upgrade/TwoColumnCells/SaveMigrationAppender.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.data.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.script.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Data/save-compatibility.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Data/system.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Events/meet-elder.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.data.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.event.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.map.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/collisions.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.data.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.event.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.map.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/01.terrain.map.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/02.props.deco.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/03.walls.map.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/village/village.data.php delete mode 100644 tests/fixtures/two-column-cells/expected/assets/Maps/village/village.event.php delete mode 100644 tests/fixtures/two-column-cells/expected/ichiloto-upgrade-report.md delete mode 100644 tests/fixtures/two-column-cells/expected/ichiloto.json delete mode 100644 tests/fixtures/two-column-cells/project/assets/Cutscenes/Cinematics/arrival/arrival.data.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Cutscenes/Cinematics/arrival/arrival.script.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Data/save-compatibility.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Data/system.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Events/meet-elder.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.data.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.event.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.map.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/collisions.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.data.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.event.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.map.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/village/layers/01.terrain.map.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/village/layers/02.props.deco.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/village/layers/03.walls.map.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/village/village.data.php delete mode 100644 tests/fixtures/two-column-cells/project/assets/Maps/village/village.event.php delete mode 100644 tests/fixtures/two-column-cells/project/ichiloto.json delete mode 100644 tests/two-column-cells-upgrade.php diff --git a/README.md b/README.md index df6a48b..2e007a8 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The CLI currently ships these commands: - `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 map scaffolding in two-column cells +- `ichiloto generate:map` for complete Engine 0.5 map scaffolding - `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) @@ -226,45 +226,13 @@ stopped. A project that is already current is left untouched. 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 steps are: +`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. -2. **Format 2: square map cells.** A map cell is two terminal columns. Every - map layer and event layer is regrouped without evaluating its PHP: a row - of odd width gains a trailing space, and a two-column glyph that would - start halfway through a cell gets a space before it. Every field x - coordinate is halved, rounding down, in map data (NPCs, wander areas, - triggers, event spawn points and scripts), common events, cinematics and - the new game start; only the integer literals change. Horizontal move - route step counts are halved too. Retired `tiles2d` crops are removed, - and the save compatibility chain gains `TwoColumnCellsMigration`, so saved - games reopen in the cell that holds their column. - -The format 2 report lists, per map and file: event cells holding two -different markers and layers that no longer line up (the map will not load -until they are fixed); what the player can no longer reach; NPCs, events and -spawn points now on a solid cell; NPCs now sharing a cell; every halved move -route step, whose exact length depends on where it starts; and any coordinate -the upgrade could not identify or that is not a literal, which it leaves for -a person rather than guessing. Region map stations, screen positions and -battle positions are not field cells and stay as they are. - -Reachability is compared rather than listing every cell that became solid. -For each map, walkable ground is flood-filled from its entry points (spawn -points and transfers that arrive on it, and the new game start) over the old -one-column collision and over the new per-cell collision, walkable meaning -what the engine lets the player step onto. The report names event areas, -NPCs and transfer triggers that were reachable before and are not after -(something counts as reached from the ground beside it), arrivals that no -longer connect to each other, and each walkable region that was cut off, with -a representative cell and the newly solid cell that most likely closed it, -such as the pair that swallowed a one-column doorway. A one-line count of -cells that became solid per map is kept for context. Re-proportioning rooms -and furniture for square cells is the author's work after the conversion. ## FIGlet Generation diff --git a/composer.json b/composer.json index 9c58f97..15f78f9 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,6 @@ "@php tests/project-startup-identity.php", "@php tests/validate-interactive-migration.php", "@php tests/legacy-project-upgrade.php", - "@php tests/two-column-cells-upgrade.php", "@php tests/project-format-refusal.php", "@php tests/validate-working-directory.php", "@php tests/battle-report.php" diff --git a/src/Support/MapScaffolder.php b/src/Support/MapScaffolder.php index 3977aac..acd28a3 100644 --- a/src/Support/MapScaffolder.php +++ b/src/Support/MapScaffolder.php @@ -4,15 +4,10 @@ namespace Ichiloto\Console\Support; -use Ichiloto\Engine\Field\MapCell; -use InvalidArgumentException; use RuntimeException; use Throwable; -/** - * Writes the three files that make up a map: its data, one terrain layer of - * two-column cells, and an event layer of blank cells matching it. - */ +/** Writes the three files that make up an Engine 0.5 map. */ final class MapScaffolder { /** @@ -43,21 +38,15 @@ public function write(string $directory, array $data, ?array $tileRows = null, b } } - $tileRows ??= self::defaultTileRows(); - $eventRows = []; - - foreach ($tileRows as $index => $row) { - try { - $eventRows[] = implode('', MapCell::getBlankRow(count(MapCell::parseRow($row, "Map row {$index}")))); - } catch (InvalidArgumentException $error) { - throw new RuntimeException($error->getMessage(), previous: $error); - } - } - if (! is_dir($directory) && ! mkdir($directory, 0755, true) && ! is_dir($directory)) { throw new RuntimeException("Could not create directory: {$directory}"); } + $tileRows ??= self::defaultTileRows(); + $eventRows = array_map( + static fn(string $row): string => str_repeat(' ', mb_strlen($row)), + $tileRows, + ); $payloads = [ 'data' => " self::renderLayer('ICHILOTO_MAP', $tileRows), @@ -98,12 +87,12 @@ public function write(string $directory, array $data, ?array $tileRows = null, b /** @return string[] */ private static function defaultTileRows(): array { - $cells = 24; - $border = str_repeat('##', $cells); + $width = 48; + $border = str_repeat('#', $width); return [ $border, - ...array_fill(0, 16, '##' . str_repeat(MapCell::BLANK, $cells - 2) . '##'), + ...array_fill(0, 16, '#' . str_repeat(' ', $width - 2) . '#'), $border, ]; } diff --git a/src/Support/NewProjectScaffolder.php b/src/Support/NewProjectScaffolder.php index 65ee8aa..f09034d 100644 --- a/src/Support/NewProjectScaffolder.php +++ b/src/Support/NewProjectScaffolder.php @@ -204,14 +204,25 @@ private function writeStarterMap(string $mapDirectory): void 'events' => [], ]; - // Every cell is two terminal columns, so walls are written as `##`. - $wall = '##' . str_repeat(' ', 44) . '##'; $tileRows = [ - str_repeat('#', 48), - ...array_fill(0, 7, $wall), - '##' . str_repeat(' ', 22) . '? ' . str_repeat(' ', 20) . '##', - ...array_fill(0, 8, $wall), - str_repeat('#', 48), + '################################################', + '# #', + '# #', + '# #', + '# #', + '# #', + '# #', + '# #', + '# ? #', + '# #', + '# #', + '# #', + '# #', + '# #', + '# #', + '# #', + '# #', + '################################################', ]; new MapScaffolder()->write($mapDirectory, $mapData, $tileRows); } @@ -514,9 +525,8 @@ private function renderSystemData(string $title, string $heroIdentity, string $b 'startingPositions' => [ 'player' => [ 'destinationMap' => self::STARTING_MAP_ID, - // A map cell is two terminal columns: cell 2 is columns 4 and 5. 'spawnPoint' => [ - 'x' => 2, + 'x' => 4, 'y' => 4, ], 'spawnSprite' => [ diff --git a/src/Upgrade/ProjectUpgradeChain.php b/src/Upgrade/ProjectUpgradeChain.php index 6513be5..b2bab29 100644 --- a/src/Upgrade/ProjectUpgradeChain.php +++ b/src/Upgrade/ProjectUpgradeChain.php @@ -5,8 +5,6 @@ namespace Ichiloto\Console\Upgrade; use Ichiloto\Console\Upgrade\Steps\SaveMetadataStep; -use Ichiloto\Console\Upgrade\Steps\TwoColumnCellsStep; -use Ichiloto\Engine\Core\ProjectFormat; use InvalidArgumentException; /** @@ -49,8 +47,7 @@ public static function createDefault(): self { return new self([ new SaveMetadataStep(), - new TwoColumnCellsStep(), - ], ProjectFormat::CURRENT); + ], SaveMetadataStep::VERSION); } public function getRecordedVersion(ProjectUpgradeContext $context): int diff --git a/src/Upgrade/Steps/TwoColumnCellsStep.php b/src/Upgrade/Steps/TwoColumnCellsStep.php deleted file mode 100644 index c23cb51..0000000 --- a/src/Upgrade/Steps/TwoColumnCellsStep.php +++ /dev/null @@ -1,660 +0,0 @@ -root); - $dictionary = $this->loadCollisionDictionary($context, $followUps); - $gridSummary = ['files' => 0, 'changed' => 0, 'padded' => 0, 'moved' => 0]; - $states = []; - - foreach ($maps as $map) { - $states[$map->id] = $this->convertMap($context, $map, $dictionary, $writes, $followUps, $gridSummary); - } - - $comparisons = array_map(static fn(?array $state): ?MapCollisionComparison => $state['comparison'] ?? null, $states); - $coordinateSummary = ['halved' => 0, 'files' => 0, 'steps' => 0, 'crops' => 0]; - $npcs = $positions = $areas = []; - - foreach ($this->findCoordinateFiles($context, $maps) as $path => [$kind, $mapId]) { - $converted = $this->convertCoordinates($context, $path, $kind, $mapId, $comparisons, $writes, $followUps, $coordinateSummary, $npcs); - array_push($positions, ...$converted['positions']); - array_push($areas, ...$converted['areas']); - } - - $this->compareReachability($states, $positions, $areas, $followUps); - - foreach ($npcs as $cell => $labels) { - // NPCs that already shared a column were already in one cell. - if (count($labels) > 1) { - $labels = array_merge(...array_values($labels)); - $followUps[] = new ProjectUpgradeFollowUp(self::SHARED_CELLS, sprintf( - '%s: %s now share one cell; the game finds only the first NPC in a cell, so move one unless their conditions never overlap.', - $cell, - implode(' and ', $labels), - )); - } - } - - $changes = [ - sprintf( - 'Regroup %d %s into two-column cells: %d of %d grid files change (%d %s padded to an even width, %d two-column %s moved right).', - count($maps), count($maps) === 1 ? 'map' : 'maps', - $gridSummary['changed'], $gridSummary['files'], - $gridSummary['padded'], $gridSummary['padded'] === 1 ? 'row' : 'rows', - $gridSummary['moved'], $gridSummary['moved'] === 1 ? 'glyph' : 'glyphs', - ), - sprintf( - 'Halve %d field x %s in %d %s.', - $coordinateSummary['halved'], $coordinateSummary['halved'] === 1 ? 'coordinate' : 'coordinates', - $coordinateSummary['files'], $coordinateSummary['files'] === 1 ? 'file' : 'files', - ), - ]; - - if ($coordinateSummary['steps'] > 0) { - $changes[] = sprintf('Halve %d horizontal move route %s (each is listed for review).', $coordinateSummary['steps'], $coordinateSummary['steps'] === 1 ? 'step' : 'steps'); - } - - if ($coordinateSummary['crops'] > 0) { - $changes[] = sprintf('Remove retired tiles2d crops from %d %s.', $coordinateSummary['crops'], $coordinateSummary['crops'] === 1 ? 'map' : 'maps'); - } - - $changes[] = $this->planSaveMigration($context, $writes, $followUps); - $blocking = count(array_filter($followUps, static fn(ProjectUpgradeFollowUp $item): bool => $item->blocking)); - $changes[] = sprintf( - 'Report %d %s that block loading and %d for review or hand conversion.', - $blocking, $blocking === 1 ? 'item' : 'items', count($followUps) - $blocking, - ); - $followUps[] = new ProjectUpgradeFollowUp(self::NEXT, 'Re-proportion furniture and rooms for square cells where they look stretched; that is the author\'s work after the conversion.'); - $followUps[] = new ProjectUpgradeFollowUp(self::NEXT, 'Run `ichiloto validate`, then play through the converted maps and cinematics.'); - - return new ProjectUpgradePlan(self::VERSION, ProjectFormat::CHANGES[self::VERSION], $changes, $writes, $followUps); - } - - public function applyPlan(ProjectUpgradeContext $context, ProjectUpgradePlan $plan): ProjectUpgradeReport - { - return ProjectUpgradeReport::applyPlan($context, $plan); - } - - /** - * Converts one map's layers and event layer and compares its collision. - * - * @param array|null $dictionary - * @param array $writes - * @param list $followUps - * @param array{files: int, changed: int, padded: int, moved: int} $summary - * @return array{comparison: MapCollisionComparison, events: list>, glyphs: list>}|null - * The converted map's collision comparison, event cells and composed - * glyphs; null when it does not load or has no collision dictionary. - */ - private function convertMap( - ProjectUpgradeContext $context, - ProjectMap $map, - ?array $dictionary, - array &$writes, - array &$followUps, - array &$summary, - ): ?array { - $layers = []; - $originalWidths = []; - $loads = true; - - foreach ($map->layerPaths as $path => $identity) { - $converted = $this->convertGrid($context, $path, $writes, $followUps, $summary); - - if ($converted === null) { - $loads = false; - continue; - } - - try { - $layers[] = new MapLayer($identity['name'], $identity['order'], $identity['decoration'], $context->getRelativePath($path), $converted['text']); - } catch (InvalidArgumentException $error) { - $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, $error->getMessage(), true); - $loads = false; - } - - if (! $identity['decoration']) { - foreach ($converted['originalWidths'] as $row => $width) { - $originalWidths[$row] = max($originalWidths[$row] ?? 0, $width); - } - } - } - - $eventGrid = null; - - if ($map->eventPath !== null) { - $converted = $this->convertGrid($context, $map->eventPath, $writes, $followUps, $summary); - $eventGrid = $converted === null ? null : $this->readEventGrid($context->getRelativePath($map->eventPath), $converted['text'], $followUps); - } - - if (! $loads || $layers === []) { - return null; - } - - try { - $set = new MapLayerSet($layers, $map->legacy); - if ($eventGrid !== null) { - $set->assertMatchingGrid($eventGrid, 'Event map ' . $context->getRelativePath((string) $map->eventPath)); - } - } catch (InvalidArgumentException $error) { - $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, "{$map->id}: {$error->getMessage()}", true); - return null; - } - - if ($dictionary === null) { - return null; - } - - try { - $comparison = new MapCollisionComparison($set, $dictionary, $originalWidths); - } catch (InvalidArgumentException $error) { - $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, "{$map->id}: collision could not be compared: {$error->getMessage()}"); - return null; - } - - $events = []; - foreach ($eventGrid ?? [] as $y => $row) { - foreach ($row as $x => $cell) { - $marker = $this->readMarker($cell); - if ($marker !== null && $comparison->isNewlySolidCell($x, $y)) { - $events[$marker][$y][] = $x; - } - } - } - - foreach ($events as $marker => $markerCells) { - $followUps[] = new ProjectUpgradeFollowUp(self::SOLID_POSITIONS, sprintf( - '%s: event %s covers newly solid cells (%s).', - $map->id, - $marker, - $this->describeCells($markerCells), - )); - } - - return [ - 'comparison' => $comparison, - 'events' => $eventGrid ?? [], - 'glyphs' => array_map(static fn(array $row): array => array_map(TerminalText::stripAnsi(...), $row), $set->getComposedGrid()), - ]; - } - - /** - * Compares where the player can walk on each map before and after, from - * the map's entry points, and reports what can no longer be reached. - * - * @param array>, glyphs: list>}|null> $states - * @param list $positions - * @param list $areas - * @param list $followUps - */ - private function compareReachability(array $states, array $positions, array $areas, array &$followUps): void - { - foreach ($states as $mapId => $state) { - if ($state === null) { - continue; - } - - $entries = $targets = []; - - foreach ($positions as $position) { - if ($position['map'] !== $mapId) { - continue; - } - if ($position['npc']) { - $targets[] = ['label' => "NPC at {$position['label']}", 'columns' => [[$position['x'], $position['y']]]]; - } else { - $entries[] = ['x' => $position['x'], 'y' => $position['y'], 'label' => $position['label']]; - } - } - - foreach ($areas as $area) { - if ($area['map'] !== $mapId) { - continue; - } - $columns = []; - for ($y = $area['y']; $y < $area['y'] + $area['height']; $y++) { - for ($x = $area['x']; $x < $area['x'] + $area['width']; $x++) { - $columns[] = [$x, $y]; - } - } - $targets[] = ['label' => "trigger at {$area['label']}", 'columns' => $columns]; - } - - foreach ($this->getEventColumns($state['events']) as $marker => $columns) { - $targets[] = ['label' => "event {$marker}", 'columns' => $columns]; - } - - $solid = count(array_merge([], ...array_values($state['comparison']->getNewlySolidCells()))); - $followUps[] = new ProjectUpgradeFollowUp(self::SOLID_CELLS, sprintf( - '%s: %d %s became solid; %s.', - $mapId, - $solid, - $solid === 1 ? 'cell' : 'cells', - $entries === [] - ? 'no entry point is known, so reachability was not compared' - : sprintf('reachability was compared from %d entry %s', count($entries), count($entries) === 1 ? 'point' : 'points'), - )); - - if ($entries === []) { - continue; - } - - $result = new MapReachabilityComparison($state['comparison'])->compareReachability($entries, $targets); - - foreach ($result['targets'] as $label) { - $followUps[] = new ProjectUpgradeFollowUp(self::UNREACHABLE, "{$mapId}: {$label} can no longer be reached from the map's entry points."); - } - - foreach ($result['entries'] as $entry) { - $followUps[] = new ProjectUpgradeFollowUp(self::UNREACHABLE, sprintf( - '%s: the arrival at %s no longer connects to %d other %s it reached before, such as %s.', - $mapId, - $entry['label'], - $entry['lost'], - $entry['lost'] === 1 ? 'entry point' : 'entry points', - $entry['example'], - )); - } - - foreach ($result['regions'] as $region) { - [$x, $y] = $region['cell']; - $choke = $region['choke'] === null - ? 'no single newly solid cell borders it' - : sprintf('likely closed by cell (%d, %d), "%s", which became solid', $region['choke'][0], $region['choke'][1], $state['glyphs'][$region['choke'][1]][$region['choke'][0]] ?? ''); - $followUps[] = new ProjectUpgradeFollowUp(self::CUT_OFF, sprintf( - '%s: %d walkable %s around cell (%d, %d) can no longer be reached; %s.', - $mapId, - $region['size'], - $region['size'] === 1 ? 'cell' : 'cells', - $x, - $y, - $choke, - )); - } - } - } - - /** - * The old columns each event marker occupied, read from its converted cells. - * - * @param list> $events - * @return array> - */ - private function getEventColumns(array $events): array - { - $columns = []; - - foreach ($events as $y => $row) { - foreach ($row as $x => $cell) { - $marker = $this->readMarker($cell); - if ($marker === null) { - continue; - } - $characters = MapCell::getCharacters($cell); - foreach ($characters as $index => $character) { - if ($character === $marker || count($characters) === 1) { - $columns[$marker][] = [$x * MapCell::COLUMNS + (count($characters) === 1 ? 0 : $index), $y]; - } - } - } - } - - return $columns; - } - - /** - * @param array $writes - * @param list $followUps - * @param array{files: int, changed: int, padded: int, moved: int} $summary - * @return array{text: string, originalWidths: list}|null - */ - private function convertGrid(ProjectUpgradeContext $context, string $path, array &$writes, array &$followUps, array &$summary): ?array - { - $relative = $context->getRelativePath($path); - $source = (string) file_get_contents($path); - $summary['files']++; - - try { - $converted = $this->grids->convertSource($source, $relative); - } catch (InvalidArgumentException $error) { - $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, "{$relative}: {$error->getMessage()}", true); - return null; - } - - if ($converted['source'] !== $source) { - $writes[$path] = $converted['source']; - $summary['changed']++; - } - - $summary['padded'] += $converted['paddedRows']; - $summary['moved'] += count($converted['insertions']); - - foreach ($converted['insertions'] as $insertion) { - $followUps[] = new ProjectUpgradeFollowUp(self::MOVED_GLYPHS, sprintf( - '%s row %d, column %d: a space was inserted before %s so it starts a cell; the rest of the row moved one column right, so check it against the map\'s other layers.', - $relative, - $insertion['row'], - $insertion['column'], - $insertion['glyph'], - )); - } - - foreach ($converted['errors'] as $error) { - $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, "{$relative} {$error}.", true); - } - - return $converted['errors'] === [] ? $converted : null; - } - - /** - * Reads a converted event layer, reporting each cell with two different markers. - * - * @param list $followUps - * @return list>|null - */ - private function readEventGrid(string $relative, string $text, array &$followUps): ?array - { - try { - $grid = MapLayer::parseGrid($text, $relative); - } catch (InvalidArgumentException $error) { - $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, $error->getMessage(), true); - return null; - } - - foreach ($grid as $y => $row) { - foreach ($row as $x => $cell) { - try { - MapCell::getMarker($cell, "{$relative} row {$y}, cell {$x}"); - } catch (InvalidArgumentException $error) { - $followUps[] = new ProjectUpgradeFollowUp(self::BLOCKING, $error->getMessage() . ' Keep one marker per cell.', true); - } - } - } - - return $grid; - } - - private function readMarker(string $cell): ?string - { - try { - return MapCell::getMarker($cell); - } catch (InvalidArgumentException) { - return null; - } - } - - /** - * Every authored file that can hold field coordinates, with what it holds. - * - * @param array $maps - * @return array Kind and map id by path. - */ - private function findCoordinateFiles(ProjectUpgradeContext $context, array $maps): array - { - $files = []; - - foreach ($maps as $map) { - $files[$map->dataPath] = [FieldCoordinateConverter::MAP_DATA, $map->id]; - } - - foreach ($this->findPhpFiles($context->getPath(self::EVENTS_DIRECTORY)) as $path) { - $files[$path] = [FieldCoordinateConverter::COMMANDS, null]; - } - - foreach ($this->findPhpFiles($context->getPath(self::CINEMATICS_DIRECTORY)) as $path) { - $files[$path] = [str_ends_with($path, '.data.php') ? FieldCoordinateConverter::CINEMATIC : FieldCoordinateConverter::COMMANDS, null]; - } - - $system = $context->getPath(self::SYSTEM_PATH); - if (is_file($system)) { - $files[$system] = [FieldCoordinateConverter::SYSTEM, null]; - } - - return $files; - } - - /** @return list */ - private function findPhpFiles(string $directory): array - { - if (! is_dir($directory)) { - return []; - } - - $paths = []; - foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) { - if (str_ends_with($file->getFilename(), '.php')) { - $paths[] = $file->getPathname(); - } - } - sort($paths); - - return $paths; - } - - /** - * @param array $comparisons - * @param array $writes - * @param list $followUps - * @param array{halved: int, files: int, steps: int, crops: int} $summary - * @param array>> $npcs NPC labels by map and new cell, then old column. - * @return array{positions: list, areas: list} - */ - private function convertCoordinates( - ProjectUpgradeContext $context, - string $path, - string $kind, - ?string $mapId, - array $comparisons, - array &$writes, - array &$followUps, - array &$summary, - array &$npcs, - ): array { - $relative = $context->getRelativePath($path); - $source = (string) file_get_contents($path); - - try { - $converted = $this->coordinates->convertSource($source, $relative, $kind, $mapId); - } catch (InvalidArgumentException $error) { - $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, "{$relative}: the file could not be read ({$error->getMessage()}); halve its field x coordinates by hand."); - return ['positions' => [], 'areas' => []]; - } - - $updated = $converted['source']; - $summary['halved'] += $converted['halved']; - $summary['steps'] += count($converted['steps']); - - foreach ($converted['manual'] as $message) { - $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, $message); - } - - foreach ($converted['steps'] as $message) { - $followUps[] = new ProjectUpgradeFollowUp(self::ROUTE_STEPS, $message); - } - - foreach ($converted['positions'] as $position) { - if ($position['npc']) { - $cell = sprintf('%s cell (%d, %d)', $position['map'], FieldCoordinateConverter::getCell($position['x']), $position['y']); - $npcs[$cell][$position['x']][] = $position['label']; - } - - $newlySolid = ($comparisons[$position['map']] ?? null)?->isNewlySolidColumn($position['x'], $position['y']); - if ($newlySolid === true) { - $followUps[] = new ProjectUpgradeFollowUp(self::SOLID_POSITIONS, sprintf( - '%s now stands on solid cell (%d, %d) of %s.', - $position['label'], - FieldCoordinateConverter::getCell($position['x']), - $position['y'], - $position['map'], - )); - } - } - - if ($converted['tiles2d']) { - try { - $document = PhpArraySourceDocument::parse($updated); - $updated = $document->withEdits([$document->removeEntryEdit(['tiles2d'])])->source; - $summary['crops']++; - $followUps[] = new ProjectUpgradeFollowUp(self::TILE_CROPS, "{$mapId}: tiles2d was removed; the map shows its terminal glyphs in graphical renderers until it has a tileset."); - } catch (Throwable $error) { - $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, "{$relative}: remove the retired tiles2d entry by hand ({$error->getMessage()}); the engine no longer reads it."); - } - } - - if ($updated !== $source) { - $writes[$path] = $updated; - $summary['files']++; - } - - return ['positions' => $converted['positions'], 'areas' => $converted['areas']]; - } - - /** - * @param array $writes - * @param list $followUps - */ - private function planSaveMigration(ProjectUpgradeContext $context, array &$writes, array &$followUps): string - { - $path = $context->getPath(SaveMetadataStep::MANIFEST_PATH); - // Format 1 creates the manifest; planned before it runs, start from what it will write. - $source = is_file($path) ? (string) file_get_contents($path) : SaveCompatibilityMetadata::renderBaseline(); - - try { - $migration = $this->saves->appendMigration($source, TwoColumnCellsMigration::class); - } catch (Throwable $error) { - $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, sprintf( - '%s: %s Register %s as the last content migration by hand, or saved games will place the player in the wrong column.', - SaveMetadataStep::MANIFEST_PATH, - $error->getMessage(), - TwoColumnCellsMigration::class, - )); - - return 'Leave the save compatibility manifest unchanged (it could not be read; see the report).'; - } - - if (! $migration['added']) { - return 'Keep the save migration for two-column cells already registered in the save compatibility manifest.'; - } - - $writes[$path] = $migration['source']; - - return sprintf( - 'Add save migration %d to %d (TwoColumnCellsMigration), so saved games reopen in the cell that holds their column.', - $migration['from'], - $migration['to'], - ); - } - - /** - * @param list $followUps - * @return array|null - */ - private function loadCollisionDictionary(ProjectUpgradeContext $context, array &$followUps): ?array - { - $path = $context->getPath(self::COLLISIONS_PATH); - - if (! is_file($path)) { - return null; - } - - try { - // The dictionary is authored PHP the engine requires the same way; grids are never evaluated. - $dictionary = (static fn(string $file): mixed => require $file)($path); - } catch (Throwable $error) { - $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, sprintf('%s could not be loaded (%s), so cells that became solid were not checked.', self::COLLISIONS_PATH, $error->getMessage())); - return null; - } - - if (! is_array($dictionary)) { - $followUps[] = new ProjectUpgradeFollowUp(self::MANUAL, self::COLLISIONS_PATH . ' does not return an array, so cells that became solid were not checked.'); - return null; - } - - return $dictionary; - } - - /** @param array> $cells Cell columns by row. */ - private function describeCells(array $cells): string - { - ksort($cells); - $rows = []; - - foreach ($cells as $y => $columns) { - $rows[] = sprintf('row %d: %s %s', $y, count($columns) === 1 ? 'cell' : 'cells', implode(', ', $columns)); - } - - return implode('; ', $rows); - } -} diff --git a/src/Upgrade/TwoColumnCells/FieldCoordinateConverter.php b/src/Upgrade/TwoColumnCells/FieldCoordinateConverter.php deleted file mode 100644 index 8ea7ddf..0000000 --- a/src/Upgrade/TwoColumnCells/FieldCoordinateConverter.php +++ /dev/null @@ -1,373 +0,0 @@ -.data.php`. */ - public const string MAP_DATA = 'map data'; - /** A command list: common events and cinematic scripts. */ - public const string COMMANDS = 'commands'; - /** A cinematic definition: its cast and finalizer. */ - public const string CINEMATIC = 'cinematic'; - /** assets/Data/system.php: only its start positions hold field coordinates. */ - public const string SYSTEM = 'system'; - - private const array STEP_FIELDS = ['direction', 'count', 'faceOnly', 'seconds']; - private const array HORIZONTAL_DIRECTIONS = ['left', 'right']; - - /** - * Converts one file. - * - * @param string $source The file's bytes. - * @param string $displayPath The project-relative path, for messages. - * @param string $kind What the file holds: one of the kind constants. - * @param string|null $mapId The map a map data file describes. - * @return array{ - * source: string, - * halved: int, - * steps: list, - * review: list, - * manual: list, - * positions: list, - * areas: list, - * tiles2d: bool - * } - * The rewritten source; how many coordinates changed; each horizontal - * move route step, described; values a person must convert; positions - * to check against the new collision, by map, with their old column - * and whether each is an NPC; trigger areas on the map, in old columns; - * and whether the file declares tile crops. - * @throws InvalidArgumentException When the file is not valid PHP. - */ - public function convertSource(string $source, string $displayPath, string $kind, ?string $mapId = null): array - { - $file = new PhpLiteralScanner()->scanSource($source, $displayPath); - $result = ['halved' => 0, 'steps' => [], 'review' => [], 'manual' => [], 'positions' => [], 'areas' => [], 'tiles2d' => false]; - $edits = []; - - foreach ($file->getArrays() as $array) { - if ($kind === self::MAP_DATA && $array->parent === null && $array->getEntry('tiles2d') !== null) { - $result['tiles2d'] = true; - } - - if ($this->isOutsideFieldData($array, $kind)) { - continue; - } - - $this->convertStep($array, $displayPath, $edits, $result); - - if ($array->getEntry('x') === null) { - continue; - } - - $role = $this->getRole($array, $kind); - $where = sprintf('%s:%d %s', $displayPath, $array->getEntry('x')->line, $this->describe($array)); - - match ($role) { - 'point' => $this->convertPoint($array, $where, $edits, $result), - 'rect' => $this->convertRect($array, $where, $edits, $result), - 'screen', 'region' => null, - default => $result['manual'][] = "{$where}: an x the upgrade could not identify as a field coordinate was left unchanged; halve it if it is one.", - }; - - $column = $array->getEntry('x')->integer; - $row = $array->getEntry('y')?->integer; - - if ($role === 'rect' && $mapId !== null && $column !== null && $row !== null && $array->parentKey !== 'wanderArea') { - $width = $array->getEntry('width') === null ? 1 : $array->getEntry('width')->integer; - $height = $array->getEntry('height') === null ? 1 : $array->getEntry('height')->integer; - if ($width !== null && $height !== null) { - $result['areas'][] = ['map' => $mapId, 'x' => $column, 'y' => $row, 'width' => $width, 'height' => $height, 'label' => $where]; - } - } - - $target = $role === 'point' ? $this->getPositionMap($array, $kind, $mapId) : null; - - if (is_string($target) && $column !== null && $row !== null) { - $result['positions'][] = [ - 'map' => $target, - 'x' => $column, - 'y' => $row, - 'label' => $where, - 'npc' => $kind === self::MAP_DATA && $this->isListedSubject($array, $kind), - ]; - } - } - - usort($edits, static fn(array $a, array $b): int => $b[0] <=> $a[0]); - foreach ($edits as [$start, $end, $text]) { - $source = substr($source, 0, $start) . $text . substr($source, $end); - } - - // The rewritten file must still be valid PHP. - new PhpLiteralScanner()->scanSource($source, $displayPath); - - return ['source' => $source] + $result; - } - - /** Arrays whose x values are not field positions at all: pixel crops, and data outside start positions. */ - private function isOutsideFieldData(PhpLiteral $array, string $kind): bool - { - return match ($kind) { - self::MAP_DATA => $array->isUnderKey('tiles2d'), - self::SYSTEM => ! $array->isUnderKey('startingPositions'), - default => false, - }; - } - - /** - * What an array holding an `x` is: a field point, a field rectangle, a - * screen position, or unknown. - */ - private function getRole(PhpLiteral $array, string $kind): string - { - $type = $array->getString('type'); - $parent = $array->parent; - - if ($type !== null && in_array($type, self::getPositionCommandTypes(), true)) { - return 'point'; - } - - $subjectKind = $array->getString('kind'); - if ($subjectKind === 'position') { - return 'point'; - } - if ($subjectKind === 'screen_position') { - return 'screen'; - } - - if ($array->parentKey === 'actor' && $parent !== null && $parent->getString('type') === 'stage_actor') { - return 'point'; - } - - if ($parent !== null && $parent->parentKey === 'waypoints') { - return 'point'; - } - - if (in_array($array->parentKey, ['spawnPoint', 'spawn_point'], true)) { - return 'point'; - } - - if (in_array($array->parentKey, ['wanderArea', 'trigger_area'], true) - || ($kind === self::MAP_DATA && $array->parentKey === 'area')) { - return 'rect'; - } - - if ($this->isListedSubject($array, $kind)) { - return 'point'; - } - - // A region map station places the map on its region's schematic, not on the field. - if ($kind === self::MAP_DATA && $array->parentKey === 'station' && $parent?->parent === null) { - return 'region'; - } - - return 'unknown'; - } - - /** - * Whether an array is one of a map's NPCs or a cinematic's cast: an entry - * of the top-level `npcs` or `cast` list, or an array with the subject's - * required name or id written inside such an entry (built by a helper - * closure and spread into the list, for example). - */ - private function isListedSubject(PhpLiteral $array, string $kind): bool - { - $listKey = match ($kind) { - self::MAP_DATA => 'npcs', - self::CINEMATIC => 'cast', - default => null, - }; - $list = $array->enclosingArray; - - if ($listKey === null || $list === null || $list->parentKey !== $listKey - || $list->parent === null || $list->parent->enclosingArray !== null) { - return false; - } - - return $array->parent === $list || $array->getEntry('name') !== null || $array->getEntry('id') !== null; - } - - /** - * Command types that carry a field x of their own: those whose finalizer - * shape requires one, and staging an actor, whose fields include one. - * - * @return list - */ - private static function getPositionCommandTypes(): array - { - $types = []; - foreach (CinematicCommandSchema::FINALIZER_COMMAND_SHAPES as $type => $shape) { - if (array_key_exists('x', $shape['required'] ?? []) || array_key_exists('x', $shape['optional'] ?? [])) { - $types[] = $type; - } - } - if (in_array('x', CinematicCommandSchema::STAGED_ACTOR_FIELDS, true)) { - $types[] = 'stage_actor'; - } - - return $types; - } - - /** - * The map a point stands on, when it should be checked against the new - * collision: a transfer's map, a spawn point's destination (or its own - * map), an NPC's map. Null when the point is not checked. - */ - private function getPositionMap(PhpLiteral $array, string $kind, ?string $mapId): ?string - { - if ($array->getString('type') === 'transfer') { - return $array->getString('map'); - } - - if (in_array($array->parentKey, ['spawnPoint', 'spawn_point'], true)) { - return $array->parent?->getString('destinationMap') ?? ($kind === self::MAP_DATA ? $mapId : null); - } - - if ($kind === self::MAP_DATA && $this->isListedSubject($array, $kind)) { - return $mapId; - } - - return null; - } - - /** @param list $edits */ - private function convertPoint(PhpLiteral $array, string $where, array &$edits, array &$result): void - { - $x = $array->getEntry('x'); - $value = $x->integer; - - if ($value === null) { - $result['manual'][] = "{$where}: x is not an integer literal; halve it by hand (rounding down)."; - return; - } - - $this->replaceInteger($x, self::getCell($value), $edits, $result); - } - - /** - * A rectangle keeps covering the cells its old columns covered: its x is - * halved and its width becomes the cells from its first column to its last. - * - * @param list $edits - */ - private function convertRect(PhpLiteral $array, string $where, array &$edits, array &$result): void - { - $x = $array->getEntry('x'); - $width = $array->getEntry('width'); - $left = $x->integer; - $columns = $width === null ? 1 : $width->integer; - - if ($left === null || $columns === null) { - $result['manual'][] = "{$where}: x or width is not an integer literal; convert the area by hand (x rounds down, width covers the same columns)."; - return; - } - - $this->replaceInteger($x, self::getCell($left), $edits, $result); - - if ($width !== null) { - $this->replaceInteger($width, self::getCell($left + $columns - 1) - self::getCell($left) + 1, $edits, $result); - } - } - - /** - * Halves a horizontal move route step count. The exact count depends on - * the column the route starts from at runtime, so every horizontal step - * is reported with both possible distances. - * - * @param list $edits - */ - private function convertStep(PhpLiteral $array, string $displayPath, array &$edits, array &$result): void - { - $direction = $array->getString('direction'); - - if ($direction === null || ! in_array($direction, MovementRouteRunner::DIRECTIONS, true) - || array_diff(array_keys($array->entries), self::STEP_FIELDS) !== [] || $array->embedded !== []) { - return; - } - - if (! in_array($direction, self::HORIZONTAL_DIRECTIONS, true) || $array->getEntry('faceOnly')?->boolean === true) { - return; - } - - $where = sprintf('%s:%d %s', $displayPath, $array->getEntry('direction')->line, $this->describe($array)); - $count = $array->getEntry('count'); - $columns = $count === null ? 1 : $count->integer; - - if ($columns === null) { - $result['manual'][] = "{$where}: the {$direction} step count is not an integer literal; halve it by hand."; - return; - } - - $cells = $columns <= 0 ? $columns : max(1, intdiv($columns, MapCell::COLUMNS)); - $shortest = intdiv($columns, MapCell::COLUMNS); - $longest = intdiv($columns + 1, MapCell::COLUMNS); - - if ($count !== null) { - $this->replaceInteger($count, $cells, $edits, $result, countAsCoordinate: false); - } - - $result['steps'][] = sprintf( - '%s: %s %d %s now %d %s; %s.', - $where, - $direction, - $columns, - $columns === 1 ? 'step is' : 'steps are', - $cells, - $cells === 1 ? 'cell' : 'cells', - $shortest === $longest - ? sprintf('the same distance is %d %s from any starting column', $shortest, $shortest === 1 ? 'cell' : 'cells') - : sprintf('the same distance is %d or %d cells, depending on the starting column', $shortest, $longest), - ); - } - - /** @param list $edits */ - private function replaceInteger(PhpLiteral $literal, int $value, array &$edits, array &$result, bool $countAsCoordinate = true): void - { - if ($literal->integer === $value) { - return; - } - - $edits[] = [$literal->start, $literal->end, (string) $value]; - - if ($countAsCoordinate) { - $result['halved']++; - } - } - - /** The cell holding a one-column x. */ - public static function getCell(int $column): int - { - return intdiv($column - ($column < 0 ? MapCell::COLUMNS - 1 : 0), MapCell::COLUMNS); - } - - private function describe(PhpLiteral $array): string - { - $path = $array->getPath(); - $type = $array->getString('type'); - - if ($path === '') { - return $type !== null ? "({$type})" : ''; - } - - return $type !== null ? "{$path} ({$type})" : $path; - } -} diff --git a/src/Upgrade/TwoColumnCells/GridSourceConverter.php b/src/Upgrade/TwoColumnCells/GridSourceConverter.php deleted file mode 100644 index 0e88246..0000000 --- a/src/Upgrade/TwoColumnCells/GridSourceConverter.php +++ /dev/null @@ -1,134 +0,0 @@ -, - * paddedRows: int, - * insertions: list, - * errors: list - * } - * @throws InvalidArgumentException When the file is not a literal grid. - */ - public function convertSource(string $source, string $displayPath): array - { - $originalText = MapGridSource::parseSource($source, $displayPath); - $content = $this->findBodySpan($source); - $indent = $content['indent']; - $raw = substr($source, $content['start'], $content['end'] - $content['start']); - $parts = preg_split('/(\r\n|\n|\r)/', $raw, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$raw]; - // PHP drops the line break before the closing marker, so the part - // after it is the (empty) remainder, not a row. - $rowCount = intdiv(count($parts) - 1, 2); - $paddedRows = 0; - $insertions = []; - $errors = []; - $originalWidths = []; - - for ($row = 0; $row < $rowCount; $row++) { - $line = $parts[$row * 2]; - $prefix = ''; - - if ($indent !== '') { - if (str_starts_with($line, $indent)) { - $prefix = $indent; - $line = substr($line, strlen($indent)); - } elseif (trim($line, " \t") === '') { - $prefix = $line; - $line = ''; - } - } - - $converted = $this->rows->convertRow($line); - $originalWidths[] = $converted['width'] - ($converted['padded'] ? 1 : 0) - count($converted['insertions']); - - if ($converted['error'] !== null) { - $errors[] = "row {$row}: {$converted['error']}"; - } - - if ($converted['padded']) { - $paddedRows++; - } - - foreach ($converted['insertions'] as $insertion) { - $insertions[] = ['row' => $row] + $insertion; - } - - $parts[$row * 2] = $prefix . $converted['text']; - } - - $updated = substr($source, 0, $content['start']) . implode('', $parts) . substr($source, $content['end']); - $text = MapGridSource::parseSource($updated, $displayPath); - - if ($errors === []) { - // The engine must read every converted row as whole cells. - MapLayer::parseGrid($text, $displayPath); - } - - return [ - 'source' => $updated, - 'originalText' => $originalText, - 'text' => $text, - 'originalWidths' => $originalWidths, - 'paddedRows' => $paddedRows, - 'insertions' => $insertions, - 'errors' => $errors, - ]; - } - - /** - * The byte span of the nowdoc body, including the line break before the - * closing marker, and the closing marker's indentation. - * - * @return array{start: int, end: int, indent: string} - */ - private function findBodySpan(string $source): array - { - $tokens = PhpToken::tokenize($source); - - foreach ($tokens as $index => $token) { - if (! $token->is(T_START_HEREDOC)) { - continue; - } - - $start = $token->pos + strlen($token->text); - $next = $tokens[$index + 1] ?? null; - $endToken = $next !== null && $next->is(T_ENCAPSED_AND_WHITESPACE) ? ($tokens[$index + 2] ?? null) : $next; - - if ($endToken === null || ! $endToken->is(T_END_HEREDOC)) { - break; - } - - preg_match('/\A[ \t]*/', $endToken->text, $matches); - - return ['start' => $start, 'end' => $endToken->pos, 'indent' => $matches[0] ?? '']; - } - - throw new InvalidArgumentException('The grid source has no nowdoc body.'); - } -} diff --git a/src/Upgrade/TwoColumnCells/MapCollisionComparison.php b/src/Upgrade/TwoColumnCells/MapCollisionComparison.php deleted file mode 100644 index e18e100..0000000 --- a/src/Upgrade/TwoColumnCells/MapCollisionComparison.php +++ /dev/null @@ -1,137 +0,0 @@ -> The engine's collision per cell. */ - public array $cells; - /** @var list> The one-column collision per original column. */ - public array $columns; - - /** - * @param MapLayerSet $layers The converted layers. - * @param array $dictionary The project's collision dictionary. - * @param list $originalWidths Each row's width before conversion, in columns. - */ - public function __construct(MapLayerSet $layers, array $dictionary, array $originalWidths) - { - $this->cells = MapCollisionResolver::resolveLayers($layers, $dictionary); - $flat = array_filter($dictionary, static fn(mixed $value): bool => $value instanceof CollisionType); - $gameplay = []; - - foreach ($layers->layers as $layer) { - if (! $layer->decoration) { - $section = $dictionary[$layer->name] ?? []; - $gameplay[] = [$layer, (is_array($section) ? $section : []) + $flat]; - } - } - - $columns = []; - - foreach ($this->cells as $y => $row) { - $columns[$y] = []; - $width = $originalWidths[$y] ?? count($row) * MapCell::COLUMNS; - - for ($column = 0; $column < $width; $column++) { - $cell = intdiv($column, MapCell::COLUMNS); - $kind = CollisionType::SOLID; - - for ($index = count($gameplay) - 1; $index >= 0; $index--) { - [$layer, $types] = $gameplay[$index]; - $characters = MapCell::getCharacters($layer->glyphs[$y][$cell] ?? MapCell::BLANK); - // A two-column glyph was one character covering both columns. - $character = $characters[count($characters) === 1 ? 0 : $column % MapCell::COLUMNS] ?? ' '; - - if ($index !== 0 && trim($character) === '') { - continue; - } - - $type = MapCollisionResolver::resolveCell($character, $types); - - if ($type !== CollisionType::PASS_THROUGH) { - $kind = $type; - break; - } - } - - $columns[$y][$column] = $kind->value; - } - } - - $this->columns = $columns; - } - - /** - * Cells that are solid now although a column they cover was not: a wall - * character paired with a floor character, such as a one-column doorway. - * - * @return array> Cell columns by row. - */ - public function getNewlySolidCells(): array - { - $cells = []; - - foreach ($this->cells as $y => $row) { - foreach ($row as $x => $kind) { - if ($this->isNewlySolidCell($x, $y)) { - $cells[$y][] = $x; - } - } - } - - return $cells; - } - - /** Whether the engine lets the player walk onto ground of this kind (MapManager::canMoveTo). */ - public static function isWalkable(?int $kind): bool - { - return $kind !== null && $kind !== CollisionType::SOLID->value && $kind !== CollisionType::NPC->value; - } - - /** Whether a cell is solid now although a column it covers was not. */ - public function isNewlySolidCell(int $x, int $y): bool - { - if (($this->cells[$y][$x] ?? null) !== CollisionType::SOLID->value) { - return false; - } - - foreach ([$x * MapCell::COLUMNS, $x * MapCell::COLUMNS + 1] as $column) { - if (isset($this->columns[$y][$column]) && $this->columns[$y][$column] !== CollisionType::SOLID->value) { - return true; - } - } - - return false; - } - - /** - * Whether something that stood on an old column now stands on a solid - * cell although that column was not solid. Null when the position is - * outside the map. - */ - public function isNewlySolidColumn(int $column, int $y): ?bool - { - $cell = FieldCoordinateConverter::getCell($column); - - if (! isset($this->cells[$y][$cell])) { - return null; - } - - return $this->cells[$y][$cell] === CollisionType::SOLID->value - && ($this->columns[$y][$column] ?? CollisionType::SOLID->value) !== CollisionType::SOLID->value; - } -} diff --git a/src/Upgrade/TwoColumnCells/MapReachabilityComparison.php b/src/Upgrade/TwoColumnCells/MapReachabilityComparison.php deleted file mode 100644 index 8e55aba..0000000 --- a/src/Upgrade/TwoColumnCells/MapReachabilityComparison.php +++ /dev/null @@ -1,233 +0,0 @@ - $entries Arrival points, by old column. - * @param list}> $targets Things to reach, - * by the old columns they occupy: event areas, NPCs and transfer triggers. A target is - * reachable when one of its positions, or a position beside one, is reachable. - * @return array{ - * targets: list, - * entries: list, - * regions: list - * } - * Targets reachable before but not after; entries that no longer connect to - * other entries they connected to before; and walkable regions that were - * reachable before and are now cut off, each with a representative cell and - * the newly solid cell that most likely closed it. - */ - public function compareReachability(array $entries, array $targets): array - { - $entries = $this->removeDuplicateEntries($entries); - $oldFloods = $newFloods = []; - - foreach ($entries as $index => $entry) { - $oldFloods[$index] = $this->flood($this->collision->columns, [[$entry['x'], $entry['y']]]); - $newFloods[$index] = $this->flood($this->collision->cells, [[FieldCoordinateConverter::getCell($entry['x']), $entry['y']]]); - } - - $oldReach = $oldFloods === [] ? [] : array_replace(...$oldFloods); - $newReach = $newFloods === [] ? [] : array_replace(...$newFloods); - $lostTargets = []; - - foreach ($targets as $target) { - $cells = array_map(static fn(array $column): array => [FieldCoordinateConverter::getCell($column[0]), $column[1]], $target['columns']); - if ($this->isReached($oldReach, $target['columns']) && ! $this->isReached($newReach, $cells)) { - $lostTargets[] = $target['label']; - } - } - - $lostEntries = []; - - // Each pair of entries is compared once; an entry on ground it cannot - // stand on still connects through the ground beside it. - foreach ($entries as $index => $entry) { - $lost = []; - foreach (array_slice($entries, $index + 1, preserve_keys: true) as $otherIndex => $other) { - if ($this->isReached($oldFloods[$index], [[$other['x'], $other['y']]]) - && ! $this->isReached($newFloods[$index], [[FieldCoordinateConverter::getCell($other['x']), $other['y']]])) { - $lost[] = $other['label']; - } - } - if ($lost !== []) { - $lostEntries[] = ['label' => $entry['label'], 'lost' => count($lost), 'example' => $lost[0]]; - } - } - - return ['targets' => $lostTargets, 'entries' => $lostEntries, 'regions' => $this->findCutOffRegions($oldReach, $newReach)]; - } - - /** - * @param list $entries - * @return list One entry per old position. - */ - private function removeDuplicateEntries(array $entries): array - { - $unique = []; - foreach ($entries as $entry) { - $unique[self::getKey($entry['x'], $entry['y'])] ??= $entry; - } - - return array_values($unique); - } - - /** - * Walkable cells reachable from the seeds. A seed counts as reached even - * when it is not walkable, since the player is placed there and can step - * off it, as the engine only checks the ground being stepped onto. - * - * @param list> $grid Collision by row and column. - * @param list $seeds - * @return array - */ - private function flood(array $grid, array $seeds): array - { - $reached = []; - $queue = []; - - foreach ($seeds as [$x, $y]) { - if (isset($grid[$y][$x])) { - $reached[self::getKey($x, $y)] = true; - $queue[] = [$x, $y]; - } - } - - while ($queue !== []) { - [$x, $y] = array_pop($queue); - foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as [$dx, $dy]) { - $nextX = $x + $dx; - $nextY = $y + $dy; - $key = self::getKey($nextX, $nextY); - if (! isset($reached[$key]) && MapCollisionComparison::isWalkable($grid[$nextY][$nextX] ?? null)) { - $reached[$key] = true; - $queue[] = [$nextX, $nextY]; - } - } - } - - return $reached; - } - - /** - * @param array $reach - * @param list $positions - */ - private function isReached(array $reach, array $positions): bool - { - foreach ($positions as [$x, $y]) { - foreach ([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]] as [$dx, $dy]) { - if (isset($reach[self::getKey($x + $dx, $y + $dy)])) { - return true; - } - } - } - - return false; - } - - /** - * Walkable cells that are no longer reachable although a column they - * cover was, grouped into connected regions. - * - * @param array $oldReach - * @param array $newReach - * @return list - */ - private function findCutOffRegions(array $oldReach, array $newReach): array - { - $candidates = []; - - foreach ($this->collision->cells as $y => $row) { - foreach ($row as $x => $kind) { - $column = $x * MapCell::COLUMNS; - if (MapCollisionComparison::isWalkable($kind) && ! isset($newReach[self::getKey($x, $y)]) - && (isset($oldReach[self::getKey($column, $y)]) || isset($oldReach[self::getKey($column + 1, $y)]))) { - $candidates[self::getKey($x, $y)] = [$x, $y]; - } - } - } - - $regions = []; - - while ($candidates !== []) { - $start = reset($candidates); - unset($candidates[self::getKey(...$start)]); - $region = [$start]; - $queue = [$start]; - - while ($queue !== []) { - [$x, $y] = array_pop($queue); - foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as [$dx, $dy]) { - $key = self::getKey($x + $dx, $y + $dy); - if (isset($candidates[$key])) { - $region[] = $candidates[$key]; - $queue[] = $candidates[$key]; - unset($candidates[$key]); - } - } - } - - $regions[] = ['size' => count($region), 'cell' => $start, 'choke' => $this->findChokeCell($region, $newReach)]; - } - - return $regions; - } - - /** - * The newly solid cell that most likely cut a region off: one bordering - * both the region and the ground still reachable, else one bordering the - * region. - * - * @param list $region - * @param array $newReach - * @return array{int, int}|null - */ - private function findChokeCell(array $region, array $newReach): ?array - { - $fallback = null; - $members = []; - foreach ($region as [$x, $y]) { - $members[self::getKey($x, $y)] = true; - } - - foreach ($this->collision->cells as $y => $row) { - foreach ($row as $x => $kind) { - if (! $this->collision->isNewlySolidCell($x, $y)) { - continue; - } - $bordersRegion = $this->isReached($members, [[$x, $y]]); - if ($bordersRegion && $this->isReached($newReach, [[$x, $y]])) { - return [$x, $y]; - } - $fallback ??= $bordersRegion ? [$x, $y] : null; - } - } - - return $fallback; - } - - private static function getKey(int $x, int $y): string - { - return $x . ':' . $y; - } -} diff --git a/src/Upgrade/TwoColumnCells/MapRowConverter.php b/src/Upgrade/TwoColumnCells/MapRowConverter.php deleted file mode 100644 index d30d7e0..0000000 --- a/src/Upgrade/TwoColumnCells/MapRowConverter.php +++ /dev/null @@ -1,110 +0,0 @@ -|\X/u'; - - /** - * @return array{text: string, width: int, padded: bool, insertions: list, error: ?string} - * The converted row; its new display width; whether it gained a - * trailing space; each glyph moved right, by its original column; and - * why the row could not be aligned, when it could not. - */ - public function convertRow(string $row): array - { - $symbols = TerminalText::visibleSymbols($row); - $widths = array_map(NormalizedRow::symbolWidth(...), $symbols); - $insertAt = []; - $column = 0; - - foreach ($widths as $index => $width) { - if ($width >= MapCell::COLUMNS && $column % MapCell::COLUMNS !== 0) { - $insertAt[$index] = $column; - $column++; - } - $column += $width; - } - - $text = $row; - $insertions = []; - - if ($insertAt !== []) { - $offsets = $this->findSymbolOffsets($row, $symbols); - - if ($offsets === null) { - return [ - 'text' => $row, - 'width' => array_sum($widths), - 'padded' => false, - 'insertions' => [], - 'error' => 'its markup could not be matched to its glyphs, so the space before a two-column glyph could not be placed', - ]; - } - - foreach (array_reverse($insertAt, true) as $index => $newColumn) { - $text = substr($text, 0, $offsets[$index]) . ' ' . substr($text, $offsets[$index]); - } - - $shift = 0; - foreach ($insertAt as $index => $newColumn) { - $insertions[] = ['column' => $newColumn - $shift, 'glyph' => TerminalText::stripAnsi($symbols[$index])]; - $shift++; - } - } - - $padded = $column % MapCell::COLUMNS !== 0; - - if ($padded) { - $text .= ' '; - $column++; - } - - return ['text' => $text, 'width' => $column, 'padded' => $padded, 'insertions' => $insertions, 'error' => null]; - } - - /** - * The byte offset in the raw row at which each visible symbol's grapheme - * begins, or null when the raw tokens do not match the symbols the engine - * reads (an escaped or unknown tag, for example). - * - * @param list $symbols - * @return list|null - */ - private function findSymbolOffsets(string $row, array $symbols): ?array - { - if (preg_match_all(self::TOKEN_PATTERN, $row, $matches, PREG_OFFSET_CAPTURE) === false) { - return null; - } - - $offsets = []; - $graphemes = []; - - foreach ($matches[0] as [$token, $offset]) { - if ($token === '' || str_starts_with($token, "\e") || preg_match('/\A<\/?[-\w=;#,?]+>\z/', $token) === 1) { - continue; - } - $offsets[] = $offset; - $graphemes[] = $token; - } - - return $graphemes === array_map(TerminalText::stripAnsi(...), $symbols) ? $offsets : null; - } -} diff --git a/src/Upgrade/TwoColumnCells/PhpLiteral.php b/src/Upgrade/TwoColumnCells/PhpLiteral.php deleted file mode 100644 index 6ba346c..0000000 --- a/src/Upgrade/TwoColumnCells/PhpLiteral.php +++ /dev/null @@ -1,168 +0,0 @@ - Entries of an array by literal key, in source order. */ - public array $entries = []; - /** @var list Array literals written inside an expression. */ - public array $embedded = []; - /** The array holding this value, or null for an array written inside an expression or at file level. */ - public ?PhpLiteral $parent = null; - /** The key this value has in its parent array; null when it has none or it could not be read. */ - public int|string|null $parentKey = null; - /** The expression an array is written inside, when it has no parent array. */ - public ?PhpLiteral $owner = null; - - public function __construct( - public readonly string $kind, - public readonly int $start, - public readonly int $end, - public readonly int $line, - public readonly string $text, - ) { - } - - /** The value of an integer literal, or null for anything else. */ - public ?int $integer { - get { - if ($this->kind !== self::SCALAR) { - return null; - } - $text = str_replace([' ', "\t", "\n", "\r", '_'], '', $this->text); - $negative = str_starts_with($text, '-'); - $digits = ltrim($text, '-'); - if (preg_match('/\A(?:0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO]?[0-7]*|[1-9][0-9]*)\z/', $digits) !== 1) { - return null; - } - $value = intval(str_replace(['0o', '0O'], '0', $digits), 0); - return $negative ? -$value : $value; - } - } - - /** The value of a plain string literal, or null for anything else. */ - public ?string $string { - get { - if ($this->kind !== self::SCALAR || strlen($this->text) < 2) { - return null; - } - $quote = $this->text[0]; - $body = substr($this->text, 1, -1); - return match ($quote) { - "'" => strtr($body, ['\\\\' => '\\', "\\'" => "'"]), - '"' => stripcslashes($body), - default => null, - }; - } - } - - /** The value of `true` or `false`, or null for anything else. */ - public ?bool $boolean { - get => $this->kind === self::SCALAR ? match (strtolower($this->text)) { - 'true' => true, - 'false' => false, - default => null, - } : null; - } - - public function getEntry(int|string $key): ?PhpLiteral - { - return $this->entries[$key] ?? null; - } - - /** The string literal held under a key, or null. */ - public function getString(int|string $key): ?string - { - return $this->getEntry($key)?->string; - } - - /** Every array literal in this value and below it, this one included when it is an array. */ - public function getArrays(): \Generator - { - if ($this->kind === self::ARRAY) { - yield $this; - } - foreach ($this->entries as $entry) { - yield from $entry->getArrays(); - } - foreach ($this->embedded as $array) { - yield from $array->getArrays(); - } - } - - /** - * The array this value belongs to: its parent, or for an array written - * inside an expression, the array holding that expression. - */ - public ?PhpLiteral $enclosingArray { - get { - for ($node = $this; $node->parent === null; $node = $node->owner) { - if ($node->owner === null) { - return null; - } - } - - return $node->parent; - } - } - - /** The value in its enclosing array that this value is, or is written inside. */ - public PhpLiteral $enclosedValue { - get { - $node = $this; - while ($node->parent === null && $node->owner !== null) { - $node = $node->owner; - } - - return $node; - } - } - - /** Whether this value sits, at any depth, under one of the keys given. */ - public function isUnderKey(string ...$keys): bool - { - for ($node = $this; $node !== null; $node = $node->enclosedValue->parent) { - $value = $node->enclosedValue; - if (is_string($value->parentKey) && in_array($value->parentKey, $keys, true)) { - return true; - } - if ($value->parent === null) { - return false; - } - } - - return false; - } - - /** - * A readable path such as `npcs[2].wanderArea`, from the outermost array - * this value belongs to. An element whose key cannot be read, such as a - * spread, is shown as `[?]`. - */ - public function getPath(): string - { - $parts = []; - for ($node = $this->enclosedValue; $node->parent !== null; $node = $node->parent->enclosedValue) { - $parts[] = match (true) { - is_int($node->parentKey) => "[{$node->parentKey}]", - $node->parentKey === null => '[?]', - default => '.' . $node->parentKey, - }; - } - - return ltrim(implode('', array_reverse($parts)), '.'); - } -} diff --git a/src/Upgrade/TwoColumnCells/PhpLiteralScanner.php b/src/Upgrade/TwoColumnCells/PhpLiteralScanner.php deleted file mode 100644 index 6f89a36..0000000 --- a/src/Upgrade/TwoColumnCells/PhpLiteralScanner.php +++ /dev/null @@ -1,230 +0,0 @@ - */ - private array $tokens = []; - - /** - * The file's outermost values holding array literals. - * - * @return PhpLiteral An expression node whose embedded values are the file's outermost arrays. - * @throws InvalidArgumentException When the file is not valid PHP. - */ - public function scanSource(string $source, string $displayPath): PhpLiteral - { - try { - token_get_all($source, TOKEN_PARSE); - } catch (ParseError $error) { - throw new InvalidArgumentException(sprintf('%s has invalid PHP at line %d: %s', $displayPath, $error->getLine(), $error->getMessage()), previous: $error); - } - - $this->tokens = array_values(array_filter( - PhpToken::tokenize($source), - static fn(PhpToken $token): bool => ! $token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, T_OPEN_TAG, T_CLOSE_TAG, T_INLINE_HTML]), - )); - $file = new PhpLiteral(PhpLiteral::OTHER, 0, strlen($source), 1, ''); - $file->embedded = $this->collectArrays(0, count($this->tokens)); - foreach ($file->embedded as $array) { - $array->owner = $file; - } - $this->tokens = []; - - return $file; - } - - /** @return list The array literals in a token range that are not inside another one there. */ - private function collectArrays(int $from, int $to): array - { - $arrays = []; - - for ($index = $from; $index < $to; $index++) { - if ($this->isArrayStart($index)) { - [$array, $index] = $this->readArray($index); - $arrays[] = $array; - } - } - - return $arrays; - } - - private function isArrayStart(int $index): bool - { - $token = $this->tokens[$index]; - - if ($token->is(T_ARRAY)) { - return ($this->tokens[$index + 1]->text ?? null) === '('; - } - - if ($token->text !== '[') { - return false; - } - - $previous = $this->tokens[$index - 1] ?? null; - - // After a value, `[` indexes it rather than opening an array. - return $previous === null || ! ($previous->is([T_VARIABLE, T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_CONSTANT_ENCAPSED_STRING, T_STATIC]) - || in_array($previous->text, [')', ']', '}'], true)); - } - - /** - * @return array{PhpLiteral, int} The array and the index of its closing token. - */ - private function readArray(int $index): array - { - $open = $this->tokens[$index]->is(T_ARRAY) ? $index + 1 : $index; - $closing = $this->findClosing($open); - $first = $this->tokens[$index]; - $last = $this->tokens[$closing]; - $array = new PhpLiteral(PhpLiteral::ARRAY, $first->pos, $last->pos + strlen($last->text), $first->line, ''); - $nextKey = 0; - $cursor = $open + 1; - - while ($cursor < $closing) { - $end = $this->findEntryEnd($cursor, $closing); - $arrow = $this->findTopLevelArrow($cursor, $end); - - if ($end > $cursor) { - if ($arrow !== null) { - $key = $this->readKey($cursor, $arrow); - $value = $this->readValue($arrow + 1, $end); - } elseif ($this->tokens[$cursor]->is(T_ELLIPSIS)) { - $key = null; - $value = $this->readValue($cursor + 1, $end); - } else { - $key = $nextKey; - $value = $this->readValue($cursor, $end); - } - - $value->parent = $array; - $value->parentKey = $key; - - if ($key === null) { - $array->embedded[] = $value; - } else { - $array->entries[$key] = $value; - if (is_int($key)) { - $nextKey = max($nextKey, $key + 1); - } - } - } - - $cursor = $end + 1; - } - - return [$array, $closing]; - } - - private function readKey(int $from, int $to): int|string|null - { - $key = $this->readValue($from, $to); - - return $key->integer ?? $key->string; - } - - private function readValue(int $from, int $to): PhpLiteral - { - $first = $this->tokens[$from]; - $last = $this->tokens[$to - 1]; - $start = $first->pos; - $end = $last->pos + strlen($last->text); - $count = $to - $from; - $isScalar = ($count === 1 && ($first->is([T_LNUMBER, T_DNUMBER, T_CONSTANT_ENCAPSED_STRING]) - || ($first->is(T_STRING) && in_array(strtolower($first->text), ['true', 'false', 'null'], true)))) - || ($count === 2 && $first->text === '-' && $last->is([T_LNUMBER, T_DNUMBER])); - - if ($isScalar) { - return new PhpLiteral(PhpLiteral::SCALAR, $start, $end, $first->line, implode('', array_map( - static fn(PhpToken $token): string => $token->text, - array_slice($this->tokens, $from, $count), - ))); - } - - if ($this->isArrayStart($from)) { - [$array, $closing] = $this->readArray($from); - if ($closing === $to - 1) { - return $array; - } - } - - $expression = new PhpLiteral(PhpLiteral::OTHER, $start, $end, $first->line, ''); - $expression->embedded = $this->collectArrays($from, $to); - foreach ($expression->embedded as $array) { - $array->owner = $expression; - } - - return $expression; - } - - private function findClosing(int $open): int - { - $depth = 0; - $count = count($this->tokens); - - for ($index = $open; $index < $count; $index++) { - $depth += $this->getDepthChange($this->tokens[$index]); - if ($depth === 0) { - return $index; - } - } - - throw new InvalidArgumentException('An array literal is not closed.'); - } - - /** The index of the `,` ending an entry, or of the array's closing token. */ - private function findEntryEnd(int $from, int $closing): int - { - $depth = 0; - - for ($index = $from; $index < $closing; $index++) { - $token = $this->tokens[$index]; - if ($depth === 0 && $token->text === ',') { - return $index; - } - $depth += $this->getDepthChange($token); - } - - return $closing; - } - - private function findTopLevelArrow(int $from, int $to): ?int - { - $depth = 0; - - for ($index = $from; $index < $to; $index++) { - $token = $this->tokens[$index]; - if ($depth === 0 && $token->is(T_DOUBLE_ARROW)) { - return $index; - } - if ($depth === 0 && $token->is([T_FN, T_FUNCTION, T_MATCH])) { - return null; - } - $depth += $this->getDepthChange($token); - } - - return null; - } - - private function getDepthChange(PhpToken $token): int - { - if ($token->is([T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES, T_ATTRIBUTE]) || in_array($token->text, ['(', '[', '{'], true)) { - return 1; - } - - return in_array($token->text, [')', ']', '}'], true) ? -1 : 0; - } -} diff --git a/src/Upgrade/TwoColumnCells/ProjectMap.php b/src/Upgrade/TwoColumnCells/ProjectMap.php deleted file mode 100644 index 9debb73..0000000 --- a/src/Upgrade/TwoColumnCells/ProjectMap.php +++ /dev/null @@ -1,92 +0,0 @@ -.data.php`, `.event.php` - * and either a `layers/` directory or a legacy `.map.php`. - */ -final readonly class ProjectMap -{ - public const string MAPS_DIRECTORY = 'assets/Maps'; - - /** - * @param string $id The map id, such as `happyville/inn/front`. - * @param string $dataPath The map data file. - * @param string|null $eventPath The event layer, when it exists. - * @param array $layerPaths Layer files and their identities, in load order. - * @param bool $legacy Whether the map has one `.map.php` instead of `layers/`. - */ - public function __construct( - public string $id, - public string $dataPath, - public ?string $eventPath, - public array $layerPaths, - public bool $legacy, - ) { - } - - /** - * Every map in a project, by id. - * - * @return array - */ - public static function findAll(string $projectRoot): array - { - $mapsRoot = $projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, self::MAPS_DIRECTORY); - - if (! is_dir($mapsRoot)) { - return []; - } - - $maps = []; - $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($mapsRoot, RecursiveDirectoryIterator::SKIP_DOTS)); - - foreach ($files as $file) { - $path = $file->getPathname(); - $directory = dirname($path); - $leaf = basename($directory); - - if (basename($path) !== $leaf . '.data.php') { - continue; - } - - $id = str_replace(DIRECTORY_SEPARATOR, '/', substr($directory, strlen($mapsRoot) + 1)); - $eventPath = $directory . DIRECTORY_SEPARATOR . $leaf . '.event.php'; - $layerDirectory = $directory . DIRECTORY_SEPARATOR . MapLayerSource::DIRECTORY; - $layerPaths = []; - $legacy = ! is_dir($layerDirectory); - - if ($legacy) { - $legacyPath = $directory . DIRECTORY_SEPARATOR . $leaf . '.map.php'; - if (is_file($legacyPath)) { - $layerPaths[$legacyPath] = ['name' => 'terrain', 'order' => 0, 'decoration' => false]; - } - } else { - $layerFiles = glob($layerDirectory . DIRECTORY_SEPARATOR . '*.php') ?: []; - sort($layerFiles); - foreach ($layerFiles as $layerPath) { - $identity = preg_match(MapLayerSource::FILENAME_PATTERN, basename($layerPath), $matches) === 1 - ? ['name' => $matches['name'], 'order' => (int) $matches['order'], 'decoration' => $matches['kind'] === 'deco'] - : null; - if ($identity !== null) { - $layerPaths[$layerPath] = $identity; - } - } - } - - $maps[$id] = new self($id, $path, is_file($eventPath) ? $eventPath : null, $layerPaths, $legacy); - } - - ksort($maps); - - return $maps; - } -} diff --git a/src/Upgrade/TwoColumnCells/SaveMigrationAppender.php b/src/Upgrade/TwoColumnCells/SaveMigrationAppender.php deleted file mode 100644 index 30d524e..0000000 --- a/src/Upgrade/TwoColumnCells/SaveMigrationAppender.php +++ /dev/null @@ -1,149 +0,0 @@ -root(); - $versionEntry = $root->entryFor('contentVersion'); - $version = $versionEntry !== null && $versionEntry->value->kind === SourceNode::SCALAR - ? substr($source, $versionEntry->value->start, $versionEntry->value->end - $versionEntry->value->start) - : null; - - if ($version === null || preg_match('/\A[0-9]+\z/', $version) !== 1) { - throw new RuntimeException('contentVersion is not an integer literal, so the save migration cannot be appended automatically.'); - } - - $from = (int) $version; - $shortName = substr($class, (int) strrpos($class, '\\') + 1); - - if (str_contains($source, $shortName . '::class') || str_contains($source, $class . '::class')) { - return ['source' => $source, 'from' => $from - 1, 'to' => $from, 'added' => false]; - } - - $imported = $this->isImported($source, $class); - $reference = $imported || ! $this->usesName($source, $shortName) ? $shortName : '\\' . $class; - $migrations = $root->entryFor('migrations'); - $container = $migrations === null ? $root : $migrations->value; - // A manifest written on one line gets its new entry on that line too. - $inline = $container->entries !== [] && $document->lineIndentBefore($container->entries[0]->start) === null; - $entry = $inline - ? "['from' => {$from}, 'to' => " . ($from + 1) . ", 'class' => {$reference}::class]" - : "[\n 'from' => {$from},\n 'to' => " . ($from + 1) . ",\n 'class' => {$reference}::class,\n]"; - $firstMigration = $inline ? "[{$entry}]" : "[\n " . str_replace("\n", "\n ", $entry) . ",\n]"; - $edits = [[$versionEntry->value->start, $versionEntry->value->end, (string) ($from + 1)]]; - - if ($migrations === null) { - $edits[] = $document->insertEntryEdit([], count($root->entries), 'migrations', $firstMigration); - } elseif ($migrations->value->kind === SourceNode::ARRAY && $migrations->value->entries === []) { - // An empty list becomes the first migration, indented from its key's line. - $edits[] = $document->replaceValueEdit(['migrations'], $firstMigration); - } elseif ($migrations->value->kind === SourceNode::ARRAY) { - $edits[] = $document->insertEntryEdit(['migrations'], count($migrations->value->entries), null, $entry); - } else { - throw new RuntimeException('migrations is not an array literal, so the save migration cannot be appended automatically.'); - } - - $updated = $document->withEdits($edits)->source; - - if (! $imported && $reference === $shortName) { - $updated = $this->addImport($updated, $class); - } - - // The result must read back with the new step at the end of the chain. - $check = PhpArraySourceDocument::parse($updated); - $newVersion = $check->root()->entryFor('contentVersion'); - if ($newVersion === null || substr($updated, $newVersion->value->start, $newVersion->value->end - $newVersion->value->start) !== (string) ($from + 1)) { - throw new RuntimeException('The save migration could not be appended without changing other manifest data.'); - } - - return ['source' => $updated, 'from' => $from, 'to' => $from + 1, 'added' => true]; - } - - private function isImported(string $source, string $class): bool - { - return preg_match('/^use\s+\\\\?' . preg_quote($class, '/') . '\s*;/m', $source) === 1; - } - - /** Whether the short name is already taken by another import or class. */ - private function usesName(string $source, string $shortName): bool - { - return preg_match('/\b' . preg_quote($shortName, '/') . '\b/', $source) === 1; - } - - /** Imports a class after the last top-level `use`, or before the `return`. */ - private function addImport(string $source, string $class): string - { - $line = "use {$class};\n"; - $lastUseEnd = null; - $returnStart = null; - $depth = 0; - $tokens = PhpToken::tokenize($source); - $previous = null; - - foreach ($tokens as $index => $token) { - if ($token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { - continue; - } - - if (in_array($token->text, ['(', '[', '{'], true)) { - $depth++; - } elseif (in_array($token->text, [')', ']', '}'], true)) { - $depth--; - } - - $startsStatement = $previous === null || $previous->is(T_OPEN_TAG) || $previous->text === ';'; - $previous = $token; - - if ($depth === 0 && $startsStatement && $token->is(T_USE)) { - for ($end = $index; $end < count($tokens) && $tokens[$end]->text !== ';'; $end++) { - } - $lastUseEnd = $tokens[$end]->pos + 1; - } - - if ($depth === 0 && $token->is(T_RETURN)) { - $returnStart = $token->pos; - break; - } - } - - if ($lastUseEnd !== null) { - $lineEnd = strpos($source, "\n", $lastUseEnd); - $at = $lineEnd === false ? strlen($source) : $lineEnd + 1; - - return substr($source, 0, $at) . $line . substr($source, $at); - } - - if ($returnStart === null) { - throw new RuntimeException('The manifest has no top-level return.'); - } - - $at = (int) strrpos(substr($source, 0, $returnStart), "\n") + 1; - - return substr($source, 0, $at) . $line . "\n" . substr($source, $at); - } -} diff --git a/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.data.php b/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.data.php deleted file mode 100644 index a6a15fc..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.data.php +++ /dev/null @@ -1,20 +0,0 @@ - 'arrival', - 'name' => 'Arrival', - 'startMap' => 'village', - 'cast' => [[ - 'id' => 'gate', - 'sprite' => ['=='], - 'x' => 3, - 'y' => 3, - 'collision' => false, - ]], - 'skip' => ['policy' => 'authored'], - 'checkpoints' => [], - 'finalizer' => [ - ['type' => 'transfer', 'map' => 'village', 'x' => 0, 'y' => 1], - ['type' => 'camera', 'operation' => 'attach'], - ], -]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.script.php b/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.script.php deleted file mode 100644 index 93d3195..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Cutscenes/Cinematics/arrival/arrival.script.php +++ /dev/null @@ -1,10 +0,0 @@ - 'camera', 'operation' => 'route', 'points' => [ - ['target' => ['kind' => 'position', 'x' => 1, 'y' => 1], 'seconds' => 0.5], - ['kind' => 'position', 'x' => 4, 'y' => 3, 'seconds' => 0.5], - ]], - ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 2], ['y' => 3]]], - ['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'right', 'count' => 2 + 1]]], -]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Data/save-compatibility.php b/tests/fixtures/two-column-cells/expected/assets/Data/save-compatibility.php deleted file mode 100644 index 10021f0..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Data/save-compatibility.php +++ /dev/null @@ -1,20 +0,0 @@ - 3, - 'migrations' => [ - [ - 'from' => 2, - 'to' => 3, - 'class' => TwoColumnCellsMigration::class, - ], - ], - 'aliases' => [ - 'maps' => [ - ['from' => 'old-village', 'to' => 'village'], - ], - ], - 'tombstones' => [], -]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Data/system.php b/tests/fixtures/two-column-cells/expected/assets/Data/system.php deleted file mode 100644 index 5bd3f69..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Data/system.php +++ /dev/null @@ -1,19 +0,0 @@ - 'Two Column Cells', - 'startingPositions' => [ - 'player' => [ - 'destinationMap' => 'village', - 'spawnPoint' => [ - 'x' => 1, - 'y' => 1, - ], - 'spawnSprite' => ['v'], - ], - ], - 'battle' => [ - // Battle positions are pixels and stay as they are. - 'formation' => [['x' => 40, 'y' => 10], ['x' => 44, 'y' => 14]], - ], -]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Events/meet-elder.php b/tests/fixtures/two-column-cells/expected/assets/Events/meet-elder.php deleted file mode 100644 index af0017f..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Events/meet-elder.php +++ /dev/null @@ -1,17 +0,0 @@ - ['type' => 'move_route', 'subject' => 'player', 'steps' => $steps]; - -return [ - ['type' => 'stage_actor', 'id' => 'crow', 'sprite' => 'v', 'x' => 3, 'y' => 1], - ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 4, 'y' => 2], 'seconds' => 0.5], - ['type' => 'field_animation', 'animation' => 'Sparkle', 'target' => ['kind' => 'screen_position', 'x' => 40, 'y' => 5]], - ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'elder', 'waypoints' => [['x' => 1], ['x' => 3, 'y' => 3], ['y' => 1]]], - $walk([['direction' => 'right', 'count' => 2]]), - [ - 'type' => 'branch', - 'conditions' => [['type' => 'switch', 'name' => 'met-elder', 'value' => true]], - 'then' => [['type' => 'transfer', 'map' => 'cave', 'x' => 2, 'y' => 1]], - 'else' => [['type' => 'camera', 'operation' => 'focus', 'kind' => 'position', 'x' => 3, 'y' => 1]], - ], -]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.data.php b/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.data.php deleted file mode 100644 index 9ca6815..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.data.php +++ /dev/null @@ -1,7 +0,0 @@ - 'Cave', - 'npcs' => [], - 'events' => [], -]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.event.php b/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.event.php deleted file mode 100644 index 5755cf0..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Maps/cave/cave.event.php +++ /dev/null @@ -1,9 +0,0 @@ - CollisionType::SOLID, - '|' => CollisionType::SOLID, - '~' => CollisionType::SOLID, - '.' => CollisionType::NONE, - ' ' => CollisionType::NONE, - 'o' => CollisionType::COLLECTABLE, -]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.data.php b/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.data.php deleted file mode 100644 index 5f974fc..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.data.php +++ /dev/null @@ -1,7 +0,0 @@ - 'Grove', - 'npcs' => [], - 'events' => [], -]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.event.php b/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.event.php deleted file mode 100644 index 6d3e1c4..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Maps/grove/grove.event.php +++ /dev/null @@ -1,8 +0,0 @@ -...........# -#...........# -####.######## -#...........# -#.....# -####### -ICHILOTO_MAP; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/02.props.deco.php b/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/02.props.deco.php deleted file mode 100644 index c8bc96f..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/02.props.deco.php +++ /dev/null @@ -1,12 +0,0 @@ -o - - - - - -ICHILOTO_MAP; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/03.walls.map.php b/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/03.walls.map.php deleted file mode 100644 index 0a9bc7c..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Maps/village/layers/03.walls.map.php +++ /dev/null @@ -1,11 +0,0 @@ - 'Village', - // A region map station is a schematic position, not a field cell. - 'station' => ['x' => 3, 'y' => 1], - 'npcs' => [ - [ - 'id' => 'elder', - 'name' => 'Elder', - 'sprite' => '@', - 'x' => 2, - 'y' => 1, - 'wanderArea' => ['x' => 1, 'y' => 1, 'width' => 3, 'height' => 2], - ], - [ - 'id' => 'guard', - 'name' => 'Guard', - 'sprite' => '@', - 'x' => 4, - 'y' => 4, - ], - [ - 'id' => 'twin', - 'name' => 'Twin', - 'sprite' => '@', - 'x' => 2, - 'y' => 1, - ], - ...array_map( - static fn(int $x): array => ['id' => "post-{$x}", 'name' => 'Post', 'sprite' => '|', 'x' => $x, 'y' => 4], - [6], - ), - ], - 'triggers' => [ - [ - 'destinationMap' => 'grove', - 'trigger_area' => ['x' => 3, 'y' => 4, 'width' => 1, 'height' => 1], - 'spawn_point' => ['x' => 1, 'y' => 1], - ], - ], - 'events' => [ - 'A' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', - 'data' => [ - 'destinationMap' => 'cave', - 'spawnPoint' => [ - 'x' => 1, - 'y' => 3, - ], - 'spawnSprite' => [MovementHeading::SOUTH->value], - ], - ], - 'B' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger', - 'data' => ['loot' => 'Potion', 'quantity' => 1], - ], - 'C' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger', - 'data' => ['loot' => 'Ether', 'quantity' => 1], - ], - 'E' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', - 'data' => [ - 'mode' => 'action', - 'script' => [ - ['type' => 'move_player', 'x' => 3, 'y' => 1], - ['type' => 'move_route', 'subject' => 'player', 'steps' => [ - ['direction' => 'left', 'count' => 1], - ['direction' => 'up'], - ['direction' => 'right'], - ['direction' => 'left', 'faceOnly' => true], - ]], - ['type' => 'transfer', 'map' => 'village', 'x' => 2, 'y' => 1], - ], - ], - ], - ], - 'decorations' => [ - 'banner' => ['x' => 12, 'y' => 0], - ], -]; diff --git a/tests/fixtures/two-column-cells/expected/assets/Maps/village/village.event.php b/tests/fixtures/two-column-cells/expected/assets/Maps/village/village.event.php deleted file mode 100644 index 50bdf6e..0000000 --- a/tests/fixtures/two-column-cells/expected/assets/Maps/village/village.event.php +++ /dev/null @@ -1,11 +0,0 @@ - 'arrival', - 'name' => 'Arrival', - 'startMap' => 'village', - 'cast' => [[ - 'id' => 'gate', - 'sprite' => ['=='], - 'x' => 7, - 'y' => 3, - 'collision' => false, - ]], - 'skip' => ['policy' => 'authored'], - 'checkpoints' => [], - 'finalizer' => [ - ['type' => 'transfer', 'map' => 'village', 'x' => 1, 'y' => 1], - ['type' => 'camera', 'operation' => 'attach'], - ], -]; diff --git a/tests/fixtures/two-column-cells/project/assets/Cutscenes/Cinematics/arrival/arrival.script.php b/tests/fixtures/two-column-cells/project/assets/Cutscenes/Cinematics/arrival/arrival.script.php deleted file mode 100644 index 8f83a60..0000000 --- a/tests/fixtures/two-column-cells/project/assets/Cutscenes/Cinematics/arrival/arrival.script.php +++ /dev/null @@ -1,10 +0,0 @@ - 'camera', 'operation' => 'route', 'points' => [ - ['target' => ['kind' => 'position', 'x' => 2, 'y' => 1], 'seconds' => 0.5], - ['kind' => 'position', 'x' => 8, 'y' => 3, 'seconds' => 0.5], - ]], - ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 5], ['y' => 3]]], - ['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'right', 'count' => 2 + 1]]], -]; diff --git a/tests/fixtures/two-column-cells/project/assets/Data/save-compatibility.php b/tests/fixtures/two-column-cells/project/assets/Data/save-compatibility.php deleted file mode 100644 index 0492f9b..0000000 --- a/tests/fixtures/two-column-cells/project/assets/Data/save-compatibility.php +++ /dev/null @@ -1,12 +0,0 @@ - 2, - 'migrations' => [], - 'aliases' => [ - 'maps' => [ - ['from' => 'old-village', 'to' => 'village'], - ], - ], - 'tombstones' => [], -]; diff --git a/tests/fixtures/two-column-cells/project/assets/Data/system.php b/tests/fixtures/two-column-cells/project/assets/Data/system.php deleted file mode 100644 index 383e9c1..0000000 --- a/tests/fixtures/two-column-cells/project/assets/Data/system.php +++ /dev/null @@ -1,19 +0,0 @@ - 'Two Column Cells', - 'startingPositions' => [ - 'player' => [ - 'destinationMap' => 'village', - 'spawnPoint' => [ - 'x' => 3, - 'y' => 1, - ], - 'spawnSprite' => ['v'], - ], - ], - 'battle' => [ - // Battle positions are pixels and stay as they are. - 'formation' => [['x' => 40, 'y' => 10], ['x' => 44, 'y' => 14]], - ], -]; diff --git a/tests/fixtures/two-column-cells/project/assets/Events/meet-elder.php b/tests/fixtures/two-column-cells/project/assets/Events/meet-elder.php deleted file mode 100644 index 829200c..0000000 --- a/tests/fixtures/two-column-cells/project/assets/Events/meet-elder.php +++ /dev/null @@ -1,17 +0,0 @@ - ['type' => 'move_route', 'subject' => 'player', 'steps' => $steps]; - -return [ - ['type' => 'stage_actor', 'id' => 'crow', 'sprite' => 'v', 'x' => 7, 'y' => 1], - ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 9, 'y' => 2], 'seconds' => 0.5], - ['type' => 'field_animation', 'animation' => 'Sparkle', 'target' => ['kind' => 'screen_position', 'x' => 40, 'y' => 5]], - ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'elder', 'waypoints' => [['x' => 3], ['x' => 7, 'y' => 3], ['y' => 1]]], - $walk([['direction' => 'right', 'count' => 4]]), - [ - 'type' => 'branch', - 'conditions' => [['type' => 'switch', 'name' => 'met-elder', 'value' => true]], - 'then' => [['type' => 'transfer', 'map' => 'cave', 'x' => 5, 'y' => 1]], - 'else' => [['type' => 'camera', 'operation' => 'focus', 'kind' => 'position', 'x' => 6, 'y' => 1]], - ], -]; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.data.php b/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.data.php deleted file mode 100644 index 9ca6815..0000000 --- a/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.data.php +++ /dev/null @@ -1,7 +0,0 @@ - 'Cave', - 'npcs' => [], - 'events' => [], -]; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.event.php b/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.event.php deleted file mode 100644 index 5755cf0..0000000 --- a/tests/fixtures/two-column-cells/project/assets/Maps/cave/cave.event.php +++ /dev/null @@ -1,9 +0,0 @@ - CollisionType::SOLID, - '|' => CollisionType::SOLID, - '~' => CollisionType::SOLID, - '.' => CollisionType::NONE, - ' ' => CollisionType::NONE, - 'o' => CollisionType::COLLECTABLE, -]; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.data.php b/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.data.php deleted file mode 100644 index 5f974fc..0000000 --- a/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.data.php +++ /dev/null @@ -1,7 +0,0 @@ - 'Grove', - 'npcs' => [], - 'events' => [], -]; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.event.php b/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.event.php deleted file mode 100644 index 6d3e1c4..0000000 --- a/tests/fixtures/two-column-cells/project/assets/Maps/grove/grove.event.php +++ /dev/null @@ -1,8 +0,0 @@ -...........# -#...........# -####.######## -#...........# -#.....# -####### -ICHILOTO_MAP; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/02.props.deco.php b/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/02.props.deco.php deleted file mode 100644 index dce28bb..0000000 --- a/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/02.props.deco.php +++ /dev/null @@ -1,12 +0,0 @@ -o - - - - - -ICHILOTO_MAP; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/03.walls.map.php b/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/03.walls.map.php deleted file mode 100644 index 96ff00d..0000000 --- a/tests/fixtures/two-column-cells/project/assets/Maps/village/layers/03.walls.map.php +++ /dev/null @@ -1,11 +0,0 @@ - 'Village', - // A region map station is a schematic position, not a field cell. - 'station' => ['x' => 3, 'y' => 1], - 'tiles2d' => [ - 'asset' => 'Graphics/Tilesets/Village.png', - 'symbols' => [ - '#' => ['x' => 16, 'y' => 0, 'width' => 16, 'height' => 32], - ], - ], - 'npcs' => [ - [ - 'id' => 'elder', - 'name' => 'Elder', - 'sprite' => '@', - 'x' => 5, - 'y' => 1, - 'wanderArea' => ['x' => 3, 'y' => 1, 'width' => 4, 'height' => 2], - ], - [ - 'id' => 'guard', - 'name' => 'Guard', - 'sprite' => '@', - 'x' => 9, - 'y' => 4, - ], - [ - 'id' => 'twin', - 'name' => 'Twin', - 'sprite' => '@', - 'x' => 4, - 'y' => 1, - ], - ...array_map( - static fn(int $x): array => ['id' => "post-{$x}", 'name' => 'Post', 'sprite' => '|', 'x' => $x, 'y' => 4], - [6], - ), - ], - 'triggers' => [ - [ - 'destinationMap' => 'grove', - 'trigger_area' => ['x' => 7, 'y' => 4, 'width' => 1, 'height' => 1], - 'spawn_point' => ['x' => 3, 'y' => 1], - ], - ], - 'events' => [ - 'A' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', - 'data' => [ - 'destinationMap' => 'cave', - 'spawnPoint' => [ - 'x' => 2, - 'y' => 3, - ], - 'spawnSprite' => [MovementHeading::SOUTH->value], - ], - ], - 'B' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger', - 'data' => ['loot' => 'Potion', 'quantity' => 1], - ], - 'C' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger', - 'data' => ['loot' => 'Ether', 'quantity' => 1], - ], - 'E' => [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', - 'data' => [ - 'mode' => 'action', - 'script' => [ - ['type' => 'move_player', 'x' => 7, 'y' => 1], - ['type' => 'move_route', 'subject' => 'player', 'steps' => [ - ['direction' => 'left', 'count' => 3], - ['direction' => 'up'], - ['direction' => 'right'], - ['direction' => 'left', 'faceOnly' => true], - ]], - ['type' => 'transfer', 'map' => 'village', 'x' => 5, 'y' => 1], - ], - ], - ], - ], - 'decorations' => [ - 'banner' => ['x' => 12, 'y' => 0], - ], -]; diff --git a/tests/fixtures/two-column-cells/project/assets/Maps/village/village.event.php b/tests/fixtures/two-column-cells/project/assets/Maps/village/village.event.php deleted file mode 100644 index 24d61b5..0000000 --- a/tests/fixtures/two-column-cells/project/assets/Maps/village/village.event.php +++ /dev/null @@ -1,11 +0,0 @@ - $tileRow) { - if (count($tileRow) !== count($eventRows[$index]) || $eventRows[$index] !== MapCell::getBlankRow(count($tileRow))) { - failMapTest('The generated event layer does not match the map width in blank cells.'); - } - } - - try { - new Ichiloto\Console\Support\MapScaffolder()->write($mapsRoot . '/half-cell', ['name' => 'Half Cell'], ['###']); - failMapTest('A map row ending halfway through a cell was written.'); - } catch (RuntimeException $exception) { - if (! str_contains($exception->getMessage(), 'halfway through a cell') || is_dir($mapsRoot . '/half-cell')) { - failMapTest('A map row ending halfway through a cell was not refused before writing: ' . $exception->getMessage()); + if (mb_strlen($tileRow) !== mb_strlen($eventRows[$index])) { + failMapTest('The generated event layer does not match the map width.'); } } @@ -126,7 +114,7 @@ function failMapTest(string $message): never failMapTest('generate:map could not replace the complete map with --force.'); } - fwrite(STDOUT, "PASS: generate:map writes the complete map layout in two-column cells.\n"); + fwrite(STDOUT, "PASS: generate:map writes the complete Engine 0.5 map layout.\n"); } catch (Throwable $throwable) { fwrite(STDERR, "FAIL: {$throwable->getMessage()}\n"); exit(1); diff --git a/tests/legacy-project-upgrade.php b/tests/legacy-project-upgrade.php index 76e897c..6a87984 100644 --- a/tests/legacy-project-upgrade.php +++ b/tests/legacy-project-upgrade.php @@ -5,9 +5,7 @@ use Ichiloto\Console\Support\SaveCompatibilityMetadata; use Ichiloto\Console\Upgrade\ProjectUpgradeContext; use Ichiloto\Console\Upgrade\Steps\SaveMetadataStep; -use Ichiloto\Engine\Core\ProjectFormat; use Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest; -use Ichiloto\Engine\IO\SaveCompatibility\TwoColumnCellsMigration; require dirname(__DIR__) . '/vendor/autoload.php'; @@ -135,22 +133,18 @@ function failUpgradeTest(string $message): never $manifest = require $manifestPath; if (($config['id'] ?? null) !== 'moon-studio/legacy-moon' || ($config['main'] ?? null) !== 'legacy-moon.php' - || ($config[ProjectFormat::KEY] ?? null) !== ProjectFormat::CURRENT) { + || ($config['format'] ?? null) !== SaveMetadataStep::VERSION) { failUpgradeTest('The upgrade did not preserve config while adopting the canonical Composer identity and recording the format.'); } - // The baseline manifest, followed by the two-column cell migration of format 2. - $expectedManifest = ['contentVersion' => 1, 'migrations' => [['from' => 0, 'to' => 1, 'class' => TwoColumnCellsMigration::class]]] - + SaveCompatibilityMetadata::baseline(); - - if ($manifest != $expectedManifest || ! is_file($legacyRoot . '/ichiloto-upgrade-report.md')) { - failUpgradeTest('The upgrade did not create the canonical compatibility manifest and chain the format 2 migration.'); + 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); $runtimeManifest = SaveCompatibilityManifest::fromProjectRoot($legacyRoot); - if ($runtimeManifest->projectId !== 'moon-studio/legacy-moon' || $runtimeManifest->contentVersion !== 1) { + if ($runtimeManifest->projectId !== 'moon-studio/legacy-moon' || $runtimeManifest->contentVersion !== 0) { failUpgradeTest('The Engine did not accept the upgraded save compatibility contract.'); } @@ -189,15 +183,12 @@ function failUpgradeTest(string $message): never } $preserved = runUpgradeCommand($consoleBin, $consoleRoot, ['--directory', $preservedRoot, '--yes']); - $preservedManifest = require $preservedRoot . '/assets/Data/save-compatibility.php'; $preservedConfig = json_decode((string) file_get_contents($preservedRoot . '/ichiloto.json'), true); if ($preserved['exitCode'] !== 0 || ($preservedConfig['id'] ?? null) !== 'studio/preserved-game' - || ($preservedManifest['custom'] ?? null) !== true - || ($preservedManifest['contentVersion'] ?? null) !== 8 - || ($preservedManifest['migrations'] ?? null) !== [['from' => 7, 'to' => 8, 'class' => TwoColumnCellsMigration::class]]) { - failUpgradeTest('Existing save identity or compatibility metadata was overwritten instead of extended: ' . $preserved['output']); + || file_get_contents($preservedRoot . '/assets/Data/save-compatibility.php') !== $customManifest) { + failUpgradeTest('Existing save identity or compatibility metadata was overwritten: ' . $preserved['output']); } $invalidRoot = $temporaryRoot . '/invalid-id'; @@ -224,4 +215,4 @@ function failUpgradeTest(string $message): never exit(1); } -fwrite(STDOUT, "PASS: legacy projects gain stable, idempotent save metadata without overwriting existing contracts, then continue through the format chain.\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 b533e45..751fc16 100644 --- a/tests/new-project-save-compatibility.php +++ b/tests/new-project-save-compatibility.php @@ -4,7 +4,6 @@ use Ichiloto\Console\Support\NewProjectScaffolder; use Ichiloto\Engine\Core\ProjectFormat; -use Ichiloto\Engine\Field\MapCell; use Ichiloto\Engine\Field\MapGridSource; use Ichiloto\Engine\Field\MapLayer; @@ -100,15 +99,17 @@ function validateScaffoldedProject(string $projectRoot, array $options = []): ar 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 !== MapCell::BLANK) !== [] - || ($terrain[$spawn['y'] ?? -1][$spawn['x'] ?? -1] ?? null) !== MapCell::BLANK) { - failScaffolderTest('The starter map is not whole two-column cells with a blank event layer and an open start cell.'); + || 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']) { diff --git a/tests/two-column-cells-upgrade.php b/tests/two-column-cells-upgrade.php deleted file mode 100644 index d998f55..0000000 --- a/tests/two-column-cells-upgrade.php +++ /dev/null @@ -1,263 +0,0 @@ - ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], - $pipes, - $projectRoot, - ); - - if (! is_resource($process)) { - failTwoColumnUpgrade('Unable to start the upgrade command.'); - } - - 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' => trim($output)]; -} - -function runFixtureGit(string $directory, string ...$arguments): void -{ - $command = array_merge(['git', '-C', $directory, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid'], $arguments); - $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); - - if (! is_resource($process)) { - failTwoColumnUpgrade('Git is required for this test.'); - } - - $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - - if (proc_close($process) !== 0) { - failTwoColumnUpgrade('git ' . implode(' ', $arguments) . ' failed: ' . $output); - } -} - -function copyTwoColumnTree(string $source, string $destination): void -{ - if (! is_dir($destination) && ! mkdir($destination, 0777, true) && ! is_dir($destination)) { - failTwoColumnUpgrade("Unable to create {$destination}."); - } - - foreach (scandir($source) ?: [] as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - - $from = $source . DIRECTORY_SEPARATOR . $entry; - $to = $destination . DIRECTORY_SEPARATOR . $entry; - is_dir($from) ? copyTwoColumnTree($from, $to) : copy($from, $to); - } -} - -function removeTwoColumnTree(string $directory): void -{ - if (! is_dir($directory)) { - return; - } - - foreach (scandir($directory) ?: [] as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - - $path = $directory . DIRECTORY_SEPARATOR . $entry; - is_dir($path) && ! is_link($path) ? removeTwoColumnTree($path) : unlink($path); - } - - rmdir($directory); -} - -/** @return array File contents by relative path, without Git's own files. */ -function readTwoColumnTree(string $root, string $prefix = ''): array -{ - $files = []; - - foreach (scandir($root . $prefix) ?: [] as $entry) { - if ($entry === '.' || $entry === '..' || ($prefix === '' && $entry === '.git')) { - continue; - } - - $relative = $prefix . '/' . $entry; - $path = $root . $relative; - $files += is_dir($path) ? readTwoColumnTree($root, $relative) : [ltrim($relative, '/') => (string) file_get_contents($path)]; - } - - ksort($files); - - return $files; -} - -/** @param array $expected @param array $actual */ -function assertSameTree(array $expected, array $actual, string $context): void -{ - if (array_keys($expected) !== array_keys($actual)) { - failTwoColumnUpgrade(sprintf( - '%s: the files differ. Missing: %s. Unexpected: %s.', - $context, - implode(', ', array_diff(array_keys($expected), array_keys($actual))) ?: 'none', - implode(', ', array_diff(array_keys($actual), array_keys($expected))) ?: 'none', - )); - } - - foreach ($expected as $path => $contents) { - if ($actual[$path] !== $contents) { - failTwoColumnUpgrade("{$context}: {$path} differs from the expected output."); - } - } -} - -function createCommittedProject(string $fixtureRoot, string $projectRoot): void -{ - copyTwoColumnTree($fixtureRoot . '/project', $projectRoot); - runFixtureGit($projectRoot, 'init', '-q'); - runFixtureGit($projectRoot, 'add', '-A'); - runFixtureGit($projectRoot, 'commit', '-q', '-m', 'Format 1 fixture'); -} - -try { - $project = $temporaryRoot . '/project'; - createCommittedProject($fixtureRoot, $project); - $original = readTwoColumnTree($project); - $expected = readTwoColumnTree($fixtureRoot . '/expected'); - - $dryRun = runTwoColumnUpgrade($consoleBin, $project, ['--dry-run']); - foreach ([ - 'This project is at format 1; Ichiloto reads format 2. The upgrade will:', - 'Regroup 3 maps into two-column cells: 5 of 8 grid files change (29 rows padded to an even width, 1 two-column glyph moved right).', - 'Halve 22 field x coordinates in 5 files.', - 'Halve 3 horizontal move route steps (each is listed for review).', - 'Remove retired tiles2d crops from 1 map.', - 'Add save migration 2 to 3 (TwoColumnCellsMigration)', - 'Report 2 items that block loading and 18 for review or hand conversion.', - 'Dry run only; no files were changed.', - ] as $line) { - if ($dryRun['exitCode'] !== 0 || ! str_contains($dryRun['output'], $line)) { - failTwoColumnUpgrade("The dry run did not list \"{$line}\": {$dryRun['output']}"); - } - } - assertSameTree($original, readTwoColumnTree($project), 'The dry run'); - - $unconfirmed = runTwoColumnUpgrade($consoleBin, $project, []); - if ($unconfirmed['exitCode'] === 0 || ! str_contains($unconfirmed['output'], 'Pass --yes')) { - failTwoColumnUpgrade('A non-interactive upgrade ran without --yes: ' . $unconfirmed['output']); - } - assertSameTree($original, readTwoColumnTree($project), 'The unconfirmed upgrade'); - - file_put_contents($project . '/notes.txt', "Unfinished work.\n"); - $dirty = runTwoColumnUpgrade($consoleBin, $project, ['--yes']); - if ($dirty['exitCode'] === 0 || ! str_contains($dirty['output'], '1 uncommitted change')) { - failTwoColumnUpgrade('The upgrade ran over uncommitted changes: ' . $dirty['output']); - } - unlink($project . '/notes.txt'); - assertSameTree($original, readTwoColumnTree($project), 'The refused upgrade'); - - $upgrade = runTwoColumnUpgrade($consoleBin, $project, ['--yes']); - if ($upgrade['exitCode'] !== 0) { - failTwoColumnUpgrade('The upgrade failed: ' . $upgrade['output']); - } - assertSameTree($expected, readTwoColumnTree($project), 'The upgrade'); - - foreach ([ - '✓ Upgraded the project to format 2.', - 'Maps that will not load until fixed:', - 'assets/Maps/village/village.event.php row 2, cell 1 holds two different markers, B and C.', - 'grove: Event map assets/Maps/grove/grove.event.php row 1 must be 4 cells wide.', - 'village: 6 cells became solid; reachability was compared from 3 entry points.', - 'village: event E can no longer be reached from the map\'s entry points.', - 'village: NPC at assets/Maps/village/village.data.php:28 npcs[1] can no longer be reached from the map\'s entry points.', - 'village: trigger at assets/Maps/village/village.data.php:46 triggers[0].trigger_area can no longer be reached', - 'village: 7 walkable cells around cell (1, 4) can no longer be reached; likely closed by cell (2, 3), ".#", which became solid.', - 'cave: the arrival at assets/Maps/village/village.data.php:56 events.A.data.spawnPoint no longer connects to 1 other entry point', - 'Follow-up list written to ' . realpath($project) . '/ichiloto-upgrade-report.md', - ] as $line) { - if (! str_contains($upgrade['output'], $line)) { - failTwoColumnUpgrade("The upgrade did not print \"{$line}\": {$upgrade['output']}"); - } - } - - // The engine reads what the upgrade wrote. - $village = MapLayerSource::loadFromDirectory($project . '/assets/Maps/village', 'village'); - if (count($village->getComposedGrid()[0]) !== 7 || count($village->getComposedGrid()[5]) !== 4) { - failTwoColumnUpgrade('The engine did not read the converted village as rows of two-column cells.'); - } - $manifest = SaveCompatibilityManifest::fromArray('fixture/two-column-cells', require $project . '/assets/Data/save-compatibility.php'); - if ($manifest->contentVersion !== 3 || $manifest->migrationFrom(2) !== TwoColumnCellsMigration::class) { - failTwoColumnUpgrade('The engine did not accept the appended save migration.'); - } - - $again = runTwoColumnUpgrade($consoleBin, $project, ['--yes']); - if ($again['exitCode'] !== 0 || ! str_contains($again['output'], 'No upgrade is needed; the project is already at format 2.')) { - failTwoColumnUpgrade('An up-to-date project was not reported as such: ' . $again['output']); - } - assertSameTree($expected, readTwoColumnTree($project), 'The repeated upgrade'); - - $allowed = $temporaryRoot . '/allowed'; - createCommittedProject($fixtureRoot, $allowed); - file_put_contents($allowed . '/notes.txt', "Unfinished work.\n"); - $allowedRun = runTwoColumnUpgrade($consoleBin, $allowed, ['--yes', '--allow-dirty']); - if ($allowedRun['exitCode'] !== 0) { - failTwoColumnUpgrade('--allow-dirty did not permit the upgrade: ' . $allowedRun['output']); - } - assertSameTree($expected + ['notes.txt' => "Unfinished work.\n"], readTwoColumnTree($allowed), 'The allowed dirty upgrade'); - - $newer = $temporaryRoot . '/newer'; - copyTwoColumnTree($fixtureRoot . '/project', $newer); - file_put_contents($newer . '/ichiloto.json', "{\n \"id\": \"fixture/newer\",\n \"format\": 99\n}\n"); - $newerRun = runTwoColumnUpgrade($consoleBin, $newer, ['--yes']); - if ($newerRun['exitCode'] === 0 || ! str_contains($newerRun['output'], 'newer than this Ichiloto reads')) { - failTwoColumnUpgrade('A project from a newer format was not refused: ' . $newerRun['output']); - } -} catch (TwoColumnUpgradeFailure $failure) { - $testFailure = $failure->getMessage(); -} finally { - removeTwoColumnTree($temporaryRoot); -} - -if (isset($testFailure)) { - fwrite(STDERR, "FAIL: {$testFailure}\n"); - exit(1); -} - -fwrite(STDOUT, "PASS: upgrade converts a project to two-column cells exactly, reports what needs a person, and protects uncommitted work.\n"); From e87dee65829ab7e6e1fc7a8bf8614139c377f955 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 30 Sep 2026 15:35:42 +0200 Subject: [PATCH 13/32] feat(generate): create a map with its kind generate:map takes --kind, one of the project's tilesets in assets/Data/Tilesets, and writes it into the new map's data as its tileset. When it is omitted it asks for the kind interactively and fails without writing anything non-interactively; a kind the project does not have is refused. A project without tilesets still gets a map without a kind, and the output says so. Changed behaviour: in a project with tilesets, a non-interactive generate:map without --kind now fails instead of creating the map. --- README.md | 2 +- src/Commands/GenerateMapCommand.php | 53 +++++++++++++++++++++++++++++ tests/generate-map.php | 34 +++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2e007a8..6f32dfd 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The CLI currently ships these commands: - `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) 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/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); From 5633eb2a2fd26d6291899f52cb65e2fa15e45e09 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 1 Oct 2026 00:11:38 +0200 Subject: [PATCH 14/32] build(deps): require the Editor at dev-develop during the 0.6 cycle The Console tracks the Editor's develop branch, and through it the Engine's, from Packagist until the 0.6 release, with minimum-stability dev and prefer-stable, as the Game does. generate:map --kind needs the Engine's tilesets, which the released 0.5 Engine lacks. The lock resolves the Editor at 61ca458 and the Engine at 2e32b6f. Before the 0.6 release, set the Editor constraint back to a tagged version. --- composer.json | 4 +++- composer.lock | 37 ++++++++++++++++++++----------------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/composer.json b/composer.json index 15f78f9..8a832cb 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": { diff --git a/composer.lock b/composer.lock index 05fcd72..ffd0e4e 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": "61ca4587a92fc11b2072ce81e213b6e7cc47fe13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ichiloto/editor/zipball/708a41988010ce37ee9d3234bb3886093db409bc", - "reference": "708a41988010ce37ee9d3234bb3886093db409bc", + "url": "https://api.github.com/repos/ichiloto/editor/zipball/61ca4587a92fc11b2072ce81e213b6e7cc47fe13", + "reference": "61ca4587a92fc11b2072ce81e213b6e7cc47fe13", "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-09-30T21:15:08+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": "2e32b6f68bcfe441b639f57b5098147fe3a224dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ichiloto/engine/zipball/6c0c3dfc177796b5af3d6951a0c85a321a3ba8ce", - "reference": "6c0c3dfc177796b5af3d6951a0c85a321a3ba8ce", + "url": "https://api.github.com/repos/ichiloto/engine/zipball/2e32b6f68bcfe441b639f57b5098147fe3a224dc", + "reference": "2e32b6f68bcfe441b639f57b5098147fe3a224dc", "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-09-30T14:13:17+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" From 4067b5345683de17cba21e47d53a99b2b457817a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 04:33:01 +0200 Subject: [PATCH 15/32] feat(battle): choose the arena's renderer as play chooses the game's ichiloto battle takes --renderer and --gpui-renderer, or asks, through the same renderer selector as play, offers a renderer update the same way, and hands the choice to the arena through ICHILOTO_RENDERER, restoring the caller's value afterwards. A renderer with --runs is refused, since a simulation draws nothing. The renderer update offer moves out of play into RendererUpdateOffer, so both commands share it. --- composer.json | 3 +- src/Commands/BattleCommand.php | 102 ++++++++++++++++++++++++---- src/Commands/PlayCommand.php | 57 ++-------------- src/Support/RendererUpdateOffer.php | 77 +++++++++++++++++++++ tests/battle-renderer.php | 99 +++++++++++++++++++++++++++ 5 files changed, 271 insertions(+), 67 deletions(-) create mode 100644 src/Support/RendererUpdateOffer.php create mode 100644 tests/battle-renderer.php diff --git a/composer.json b/composer.json index 8a832cb..2f7127a 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,8 @@ "@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" ] } } diff --git a/src/Commands/BattleCommand.php b/src/Commands/BattleCommand.php index 9f20d58..e9232c0 100644 --- a/src/Commands/BattleCommand.php +++ b/src/Commands/BattleCommand.php @@ -3,6 +3,14 @@ namespace Ichiloto\Console\Commands; 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\Resolution\CombatHitResult; use Ichiloto\Engine\Battle\Resolution\ElementalOutcome; use Ichiloto\Engine\Battle\Simulation\BattleSimulator; @@ -34,13 +42,44 @@ )] 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('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 @@ -62,7 +101,33 @@ public function execute(InputInterface $input, OutputInterface $output): int // 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); + $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; + } + $this->rendererUpdateOffer->offer($workingDirectory, $renderer->id, $input, $output); + + return $this->play($workingDirectory, $input->getOption('troop'), $renderer->id, $output); + } + + // A simulation draws nothing, so a renderer is a mistake to point out. + if ($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; } $previousDirectory = getcwd(); @@ -521,7 +586,7 @@ 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, OutputInterface $output): int { $previousDirectory = getcwd(); @@ -531,28 +596,35 @@ 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 ?? ''); } 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; } + /** Runs the arena, which opens on its list of troops; naming one skips straight to that fight. */ + protected function runArena(string $projectName, string $troop): void + { + new Game($projectName, options: [ + 'starting_scene' => ArenaScene::class, + 'arena_troop' => $troop, + ])->run(); + } + /** * Reads the project's name. * diff --git a/src/Commands/PlayCommand.php b/src/Commands/PlayCommand.php index 933c6c3..e155b76 100644 --- a/src/Commands/PlayCommand.php +++ b/src/Commands/PlayCommand.php @@ -10,11 +10,11 @@ use Ichiloto\Console\Support\GameLaunchCommandBuilder; use Ichiloto\Console\Support\GameProcessLauncher; use Ichiloto\Console\Support\ProjectFormatCheck; +use Ichiloto\Console\Support\RendererUpdateOffer; use Ichiloto\Console\Support\TerminalInteractivity; use Ichiloto\Console\Support\SourceRendererUpdateChecker; use Ichiloto\Console\Support\SourceRendererUpdater; use Ichiloto\Console\Util\Path; -use Closure; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -22,9 +22,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Formatter\OutputFormatter; -use Throwable; - -use function Laravel\Prompts\select; #[AsCommand( name: 'play', @@ -42,11 +39,7 @@ class PlayCommand extends Command private readonly GameProcessLauncher $gameProcessLauncher; - private readonly SourceRendererUpdateChecker $rendererUpdateChecker; - - private readonly SourceRendererUpdater $rendererUpdater; - - private readonly Closure $rendererUpdatePrompt; + private readonly RendererUpdateOffer $rendererUpdateOffer; public function __construct( ?RendererRegistry $rendererRegistry = null, @@ -63,11 +56,8 @@ public function __construct( $this->terminalInteractivity = $terminalInteractivity ?? new TerminalInteractivity(); $this->launchCommandBuilder = $launchCommandBuilder ?? new GameLaunchCommandBuilder(); $this->gameProcessLauncher = $gameProcessLauncher ?? new GameProcessLauncher($this->launchCommandBuilder); - $this->rendererUpdateChecker = $rendererUpdateChecker ?? new SourceRendererUpdateChecker(); - $this->rendererUpdater = $rendererUpdater ?? new SourceRendererUpdater($this->rendererUpdateChecker); - $this->rendererUpdatePrompt = $rendererUpdatePrompt === null - ? static fn (string $label, array $options): int|string => select(label: $label, options: $options) - : Closure::fromCallable($rendererUpdatePrompt); + $this->rendererUpdateOffer = new RendererUpdateOffer($rendererUpdateChecker, $rendererUpdater, + $this->terminalInteractivity, $rendererUpdatePrompt); parent::__construct(); } @@ -163,7 +153,7 @@ public function execute(InputInterface $input, OutputInterface $output): int return $this->launchInTmux($workingDirectory, $mainFile, $errorLogFile, $renderer->id, $input, $output); } - $this->offerRendererUpdate($workingDirectory, $renderer->id, $input, $output); + $this->rendererUpdateOffer->offer($workingDirectory, $renderer->id, $input, $output); $resultCode = $this->gameProcessLauncher->launch( workingDirectory: $workingDirectory, @@ -231,7 +221,7 @@ private function launchInTmux( $launchCommand = $this->launchCommandBuilder->buildCrashPreservingCommand($gameCommand, 'Ichiloto game'); if (! $this->tmuxSessionExists($sessionName)) { - $this->offerRendererUpdate($workingDirectory, $rendererId, $input, $output); + $this->rendererUpdateOffer->offer($workingDirectory, $rendererId, $input, $output); passthru($this->launchCommandBuilder->buildTmuxNewSessionCommand( sessionName: $sessionName, workingDirectory: $workingDirectory, @@ -249,41 +239,6 @@ private function launchInTmux( return $exitCode; } - private function offerRendererUpdate( - string $workingDirectory, - string $rendererId, - InputInterface $input, - OutputInterface $output, - ): void - { - try { - $update = $this->rendererUpdateChecker->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->rendererUpdatePrompt)('Renderer update available', [ - 'update' => 'Update now', - 'continue' => 'Continue this launch', - 'skip' => 'Skip this version', - ]); - if ($choice === 'update') { - $installed = $this->rendererUpdater->update($workingDirectory, $rendererId, $output); - $output->writeln($installed ? 'Renderer updated.' : 'Renderer is already current.'); - } elseif ($choice === 'skip') { - $this->rendererUpdater->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.'); - } - } - /** * Applies tmux options that help preserve crash output. * 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/tests/battle-renderer.php b/tests/battle-renderer.php new file mode 100644 index 0000000..1303ba9 --- /dev/null +++ b/tests/battle-renderer.php @@ -0,0 +1,99 @@ + */ + public array $arenas = []; + + protected function runArena(string $projectName, string $troop): void + { + $this->arenas[] = ['project' => $projectName, 'troop' => $troop, 'renderer' => getenv('ICHILOTO_RENDERER')]; + } +} + +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 = sys_get_temp_dir() . '/ichiloto-battle-renderer-' . bin2hex(random_bytes(6)); +mkdir($project . '/vendor', 0o777, true); +file_put_contents($project . '/ichiloto.json', json_encode(['name' => 'Arena Test'])); +file_put_contents($project . '/vendor/autoload.php', " 'gpui', '--troop' => 'Bat x 2']); + assertBattleRenderer($status === Command::SUCCESS, 'battle --renderer gpui succeeds'); + assertBattleRenderer($command->arenas === [['project' => 'Arena Test', 'troop' => 'Bat x 2', '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'); + unlink($project . '/vendor/autoload.php'); + unlink($project . '/ichiloto.json'); + rmdir($project . '/vendor'); + rmdir($project); +} + +fwrite(STDOUT, "PASS: battle selects its renderer as play does and scopes it to the arena.\n"); From 0298503c1be9a750f0c9d59b9883c3eb376defd2 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 05:27:03 +0200 Subject: [PATCH 16/32] feat(battle): set up the battle test party with --member ichiloto battle takes one --member per party member (up to four), as Actor[:level][,Slot=item...], with actors and items by id or name; without it the party is the starting party. The setup is the engine's battle test setup, used for playing and for --runs alike: playing hands it to the arena, which builds a fresh party from it for every fight, and --runs builds its party from it. Anything the party cannot be built from (an unknown actor or item, a bad level, a slot the actor lacks, equipment that does not fit) is refused before any battle starts, every problem named. Removed: the console's own starting-party builder, which loaded actor files by name and bypassed the engine's actor identity resolution; the engine's setup replaces it. Also removed the README note calling ichiloto battle a placeholder, which was no longer true. A project whose engine has no battle test setup is told to update its engine. --- README.md | 24 +++- composer.json | 3 +- src/Battle/BattleMemberOption.php | 43 +++++++ src/Commands/BattleCommand.php | 191 +++++++++++++++++++++--------- tests/battle-members.php | 106 +++++++++++++++++ tests/battle-renderer.php | 21 ++-- tests/fixtures/battle-project.php | 50 ++++++++ 7 files changed, 365 insertions(+), 73 deletions(-) create mode 100644 src/Battle/BattleMemberOption.php create mode 100644 tests/battle-members.php create mode 100644 tests/fixtures/battle-project.php diff --git a/README.md b/README.md index 6f32dfd..2f68a91 100644 --- a/README.md +++ b/README.md @@ -162,12 +162,27 @@ 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" +``` + +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 --troop "Bat x 2" --runs 100 +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 @@ -251,7 +266,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 diff --git a/composer.json b/composer.json index 2f7127a..a6ee03e 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,8 @@ "@php tests/project-format-refusal.php", "@php tests/validate-working-directory.php", "@php tests/battle-report.php", - "@php tests/battle-renderer.php" + "@php tests/battle-renderer.php", + "@php tests/battle-members.php" ] } } diff --git a/src/Battle/BattleMemberOption.php b/src/Battle/BattleMemberOption.php new file mode 100644 index 0000000..6414251 --- /dev/null +++ b/src/Battle/BattleMemberOption.php @@ -0,0 +1,43 @@ + $equipment Item references by slot name, null for an empty slot. + */ + private function __construct(public string $actor, public ?int $level, public array $equipment) {} + + 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 = []; + foreach ($parts as $part) { + [$slot, $item] = array_pad(array_map(trim(...), explode('=', $part, 2)), 2, null); + if ($slot === '' || $item === null) { + throw new InvalidArgumentException(sprintf('--member "%s": equipment is given as Slot=item, such as Weapon=Iron Sword.', $value)); + } + $equipment[$slot] = $item === '' ? null : $item; + } + + return new self($actor, $level === null ? null : (int) $level, $equipment); + } +} diff --git a/src/Commands/BattleCommand.php b/src/Commands/BattleCommand.php index e9232c0..0fbfeb5 100644 --- a/src/Commands/BattleCommand.php +++ b/src/Commands/BattleCommand.php @@ -2,6 +2,7 @@ namespace Ichiloto\Console\Commands; +use Ichiloto\Console\Battle\BattleMemberOption; use Ichiloto\Console\Battle\ParticipantSnapshot; use Ichiloto\Console\Renderer\RendererRegistry; use Ichiloto\Console\Renderer\RendererSelector; @@ -16,6 +17,8 @@ use Ichiloto\Engine\Battle\Simulation\BattleSimulator; use Ichiloto\Engine\Core\Game; use Ichiloto\Engine\Scenes\Arena\ArenaScene; +use Ichiloto\Engine\Scenes\Arena\BattleTestMember; +use Ichiloto\Engine\Scenes\Arena\BattleTestSetup; use Ichiloto\Engine\Battle\Simulation\SimulationReport; use Ichiloto\Engine\Entities\Character; use Ichiloto\Engine\Entities\EquipmentSlot; @@ -26,10 +29,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; @@ -77,6 +82,9 @@ public function configure(): void ->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('member', 'm', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + sprintf('A party member, as Actor[:level][,Slot=item...]; repeat for each, up to %d. Without it, the starting party.', + class_exists(BattleTestSetup::class) ? BattleTestSetup::MAX_MEMBERS : 4)) ->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.'); @@ -98,33 +106,16 @@ 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) { - $rendererOption = $input->getOption('renderer'); - if ($rendererOption !== null && ! is_string($rendererOption)) { - $output->writeln('The renderer option must be a renderer ID.'); + 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::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; - } - $this->rendererUpdateOffer->offer($workingDirectory, $renderer->id, $input, $output); - - return $this->play($workingDirectory, $input->getOption('troop'), $renderer->id, $output); + return Command::FAILURE; } + $playing = $input->getOption('runs') === null; + // A simulation draws nothing, so a renderer is a mistake to point out. - if ($input->getOption('renderer') !== null || (bool) $input->getOption('gpui-renderer')) { + 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; @@ -140,10 +131,22 @@ 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 (! $playing) { + $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 ?: '.'); @@ -153,7 +156,32 @@ 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; + } + $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; @@ -586,7 +614,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, string $rendererId, OutputInterface $output): int + protected function play(string $workingDirectory, ?string $troop, string $rendererId, BattleTestSetup $setup, + OutputInterface $output): int { $previousDirectory = getcwd(); @@ -603,7 +632,7 @@ protected function play(string $workingDirectory, ?string $troop, string $render putenv("{$variable}={$rendererId}"); try { - $this->runArena($this->projectName($workingDirectory), $troop ?? ''); + $this->runArena($this->projectName($workingDirectory), $troop ?? '', $setup); } catch (Throwable $throwable) { $output->writeln('The arena could not start: ' . $throwable->getMessage() . ''); @@ -616,15 +645,88 @@ protected function play(string $workingDirectory, ?string $troop, string $render return Command::SUCCESS; } - /** Runs the arena, which opens on its list of troops; naming one skips straight to that fight. */ - protected function runArena(string $projectName, string $troop): void + 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 battle test setup the --member options describe, or the starting + * party without them. 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(); + if ($members === []) { + return 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; + } + $setupMembers[] = new BattleTestMember($actorId, $option->level ?? $actors->require($actorId, 'ichiloto battle')->createCharacter()->level, + $equipment); + } + $setup = $setupMembers === [] ? null : new BattleTestSetup($setupMembers); + $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; + } + /** * Reads the project's name. * @@ -748,34 +850,13 @@ protected function registerProjectStores(): void ConfigStore::put(ItemStore::class, new ItemStore()); } - if (! ConfigStore::has(EnemyStore::class)) { - ConfigStore::put(EnemyStore::class, new EnemyStore()); - } - } - - /** - * 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 (! ConfigStore::has(ActorStore::class)) { + ConfigStore::put(ActorStore::class, new ActorStore()); } - 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/tests/battle-members.php b/tests/battle-members.php new file mode 100644 index 0000000..02265ca --- /dev/null +++ b/tests/battle-members.php @@ -0,0 +1,106 @@ +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(); + +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'); + + [, $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'); +} 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 index 1303ba9..773f119 100644 --- a/tests/battle-renderer.php +++ b/tests/battle-renderer.php @@ -14,23 +14,25 @@ use Ichiloto\Console\Renderer\RendererSelector; use Ichiloto\Console\Support\SourceRendererUpdateChecker; use Ichiloto\Console\Support\TerminalInteractivity; +use Ichiloto\Engine\Scenes\Arena\BattleTestSetup; use Symfony\Component\Console\Application; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\ApplicationTester; require dirname(__DIR__) . '/vendor/autoload.php'; +require __DIR__ . '/fixtures/battle-project.php'; /** Records what the arena would have run with, instead of running it. */ #[AsCommand(name: 'battle')] final class RecordingBattleCommand extends BattleCommand { - /** @var list */ + /** @var list */ public array $arenas = []; - protected function runArena(string $projectName, string $troop): void + protected function runArena(string $projectName, string $troop, BattleTestSetup $setup): void { - $this->arenas[] = ['project' => $projectName, 'troop' => $troop, 'renderer' => getenv('ICHILOTO_RENDERER')]; + $this->arenas[] = ['project' => $projectName, 'troop' => $troop, 'renderer' => getenv('ICHILOTO_RENDERER'), 'setup' => $setup]; } } @@ -63,16 +65,14 @@ function runBattleRenderer(string $project, array $options, ?callable $prompt = return [$command, $status, $tester->getDisplay()]; } -$project = sys_get_temp_dir() . '/ichiloto-battle-renderer-' . bin2hex(random_bytes(6)); -mkdir($project . '/vendor', 0o777, true); -file_put_contents($project . '/ichiloto.json', json_encode(['name' => 'Arena Test'])); -file_put_contents($project . '/vendor/autoload.php', " 'gpui', '--troop' => 'Bat x 2']); assertBattleRenderer($status === Command::SUCCESS, 'battle --renderer gpui succeeds'); - assertBattleRenderer($command->arenas === [['project' => 'Arena Test', 'troop' => 'Bat x 2', 'renderer' => 'gpui']], + 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'); @@ -90,10 +90,7 @@ function runBattleRenderer(string $project, array $options, ?callable $prompt = 'a renderer with --runs is refused'); } finally { putenv('ICHILOTO_RENDERER'); - unlink($project . '/vendor/autoload.php'); - unlink($project . '/ichiloto.json'); - rmdir($project . '/vendor'); - rmdir($project); + 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..47ae551 --- /dev/null +++ b/tests/fixtures/battle-project.php @@ -0,0 +1,50 @@ + '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"); + } + file_put_contents($root . '/assets/Data/items.php', <<<'ITEMS' + isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($root); +} From 3cd779e822f10cc7438e8ae426c31c1ff3247be7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 19:34:20 +0200 Subject: [PATCH 17/32] feat(battle): give a battle test member commands, skills and summons There was no way to test a command, skill or summon before the game made it available. --member now takes Commands=, Skills= and Summons=, each a list separated by |: Commands replaces the member's command menu, by command id or the label the project shows for it; Skills grants abilities or spells from the project's skill catalogue; Summons grants summons by id. They resolve through the Engine's own command types, skill catalogue and summon library, and every unknown reference is named before a battle starts. The keys never become equipment slots. They reach the Engine's battle test setup for both playing and --runs. A project engine that cannot take them says so instead of ignoring them. --- README.md | 19 ++++++ composer.json | 3 +- src/Battle/BattleMemberOption.php | 77 ++++++++++++++++++++--- src/Commands/BattleCommand.php | 101 +++++++++++++++++++++++++++++- tests/battle-member-loadouts.php | 58 +++++++++++++++++ tests/battle-members.php | 7 +++ 6 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 tests/battle-member-loadouts.php diff --git a/README.md b/README.md index 2f68a91..633488e 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,25 @@ built from is refused, every problem named, before a battle starts: 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: + +```bash +ichiloto battle --member "Liora:20,Commands=attack|magic|summon|item,Skills=Burn I|Heal I,Summons=ifrit" --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*: diff --git a/composer.json b/composer.json index a6ee03e..17cc1b3 100644 --- a/composer.json +++ b/composer.json @@ -58,7 +58,8 @@ "@php tests/validate-working-directory.php", "@php tests/battle-report.php", "@php tests/battle-renderer.php", - "@php tests/battle-members.php" + "@php tests/battle-members.php", + "@php tests/battle-member-loadouts.php" ] } } diff --git a/src/Battle/BattleMemberOption.php b/src/Battle/BattleMemberOption.php index 6414251..8fdda35 100644 --- a/src/Battle/BattleMemberOption.php +++ b/src/Battle/BattleMemberOption.php @@ -7,17 +7,34 @@ use InvalidArgumentException; /** - * One --member of ichiloto battle, as typed: `Actor[:level][,Slot=item...]`, - * for example `Kaelion:20,Weapon=Iron Sword,Body=Leather Vest`. The actor - * and items are references (an id or a name) for the project to resolve; a - * slot given no item is empty. + * One --member of ichiloto battle, as typed: + * `Actor[:level][,Slot=item...][,Commands=a|b][,Skills=a|b][,Summons=a|b]`, + * for example `Liora:20,Weapon=Wooden Staff,Commands=attack|magic|summon,Skills=Burn I|Heal I,Summons=ifrit`. + * + * The actor, items, commands, skills and summons are references for the + * project to resolve. A slot given no item is empty. Commands replaces the + * member's command menu; Skills and Summons add test-only grants on top of + * what the member already knows. */ final readonly class BattleMemberOption { + /** Keys that name a loadout list, never an equipment slot. */ + public const array LOADOUT_KEYS = ['commands', 'skills', 'summons']; + /** * @param array $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) {} + 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 { @@ -30,14 +47,54 @@ public static function parse(string $value): self throw new InvalidArgumentException(sprintf('--member "%s": the level must be a whole number from 1.', $value)); } $equipment = []; + $loadout = []; foreach ($parts as $part) { - [$slot, $item] = array_pad(array_map(trim(...), explode('=', $part, 2)), 2, null); - if ($slot === '' || $item === null) { - throw new InvalidArgumentException(sprintf('--member "%s": equipment is given as Slot=item, such as Weapon=Iron Sword.', $value)); + [$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[$slot] = $item === '' ? null : $item; + $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 => 'ifrit' }, + )); + } + $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 new self($actor, $level === null ? null : (int) $level, $equipment); + return $names; } } diff --git a/src/Commands/BattleCommand.php b/src/Commands/BattleCommand.php index 0fbfeb5..8417640 100644 --- a/src/Commands/BattleCommand.php +++ b/src/Commands/BattleCommand.php @@ -12,10 +12,13 @@ use Ichiloto\Console\Support\SourceRendererUpdater; use Ichiloto\Console\Support\TerminalInteractivity; use InvalidArgumentException; +use Ichiloto\Engine\Battle\BattleCommandType; 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; @@ -83,7 +86,7 @@ public function configure(): void ->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('member', 'm', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - sprintf('A party member, as Actor[:level][,Slot=item...]; repeat for each, up to %d. Without it, the starting party.', + 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('renderer', null, InputOption::VALUE_REQUIRED, sprintf('Renderer to play the battle in (%s)', implode(', ', $this->rendererRegistry->ids()))) @@ -715,8 +718,17 @@ protected function createSetup(array $members): BattleTestSetup } $equipment[$slot] = $item?->id; } - $setupMembers[] = new BattleTestMember($actorId, $option->level ?? $actors->require($actorId, 'ichiloto battle')->createCharacter()->level, - $equipment); + $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; + } + $setupMembers[] = new BattleTestMember($actorId, $level, $equipment, + $this->resolveCommands($option, $problems), $this->resolveSkills($option, $problems), $this->resolveSummons($option, $problems)); } $setup = $setupMembers === [] ? null : new BattleTestSetup($setupMembers); $problems = [...$problems, ...($setup?->getProblems($actors, $items) ?? [])]; @@ -727,6 +739,89 @@ protected function createSetup(array $members): BattleTestSetup return $setup; } + /** + * 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. * 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 index 02265ca..84d36c5 100644 --- a/tests/battle-members.php +++ b/tests/battle-members.php @@ -96,6 +96,13 @@ function runBattleMembers(string $project, array $members, array $options = []): 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'); + } + [, $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'); From b63d832c5b13521feca26201a1202d99bd5741bb Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 2 Oct 2026 19:47:46 +0200 Subject: [PATCH 18/32] fix(battle): refuse battle test loadouts in simulations, and name every problem --runs simulates with every battler attacking, so a member's Commands, Skills and Summons would never be used and its numbers would pretend otherwise. A simulation with a loadout is now refused, explaining that playing the fight uses them; levels and equipment still simulate. This narrows the previous commit, which passed loadouts to --runs. A member whose loadout does not resolve is no longer built, so every unknown command, skill and summon is reported together instead of the first empty list stopping the setup. The README example gives Ifrit to Kaelion, who may hold it. Tests cover the loadout reaching the setup by id and label, every unknown reference named, an ineligible summon holder refused by the Engine, and the --runs refusal. --- README.md | 5 +++-- src/Commands/BattleCommand.php | 32 +++++++++++++++++++++++++++++-- tests/battle-members.php | 27 ++++++++++++++++++++++++++ tests/fixtures/battle-project.php | 26 +++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 633488e..8664a90 100644 --- a/README.md +++ b/README.md @@ -190,10 +190,11 @@ tried before the game makes it available: 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: +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 --member "Liora:20,Commands=attack|magic|summon|item,Skills=Burn I|Heal I,Summons=ifrit" --troop "Loch Ness" +ichiloto battle --member "Kaelion:20,Commands=attack|skill|summon|item,Summons=ifrit" --member "Liora:20,Skills=Burn I|Heal I" --troop "Loch Ness" ``` Choose the renderer as `ichiloto play` does, with `--renderer` or diff --git a/src/Commands/BattleCommand.php b/src/Commands/BattleCommand.php index 8417640..b138c22 100644 --- a/src/Commands/BattleCommand.php +++ b/src/Commands/BattleCommand.php @@ -142,6 +142,7 @@ public function execute(InputInterface $input, OutputInterface $output): int // One setup for both: the party played with and the party simulated. $setup = $this->createSetup((array) $input->getOption('member')); if (! $playing) { + $this->refuseUnsimulatedLoadouts($setup); $party = $setup->createParty($this->getActorStore(), $this->getItemStore()); $troops = $this->loadTroops($input->getOption('troop')); } @@ -727,8 +728,14 @@ protected function createSetup(array $members): BattleTestSetup $problems[] = sprintf("--member %s: this project's engine cannot set a member's commands, skills or summons; update its engine.", $option->actor); continue; } - $setupMembers[] = new BattleTestMember($actorId, $level, $equipment, - $this->resolveCommands($option, $problems), $this->resolveSkills($option, $problems), $this->resolveSummons($option, $problems)); + $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); $problems = [...$problems, ...($setup?->getProblems($actors, $items) ?? [])]; @@ -739,6 +746,27 @@ protected function createSetup(array $members): BattleTestSetup 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. diff --git a/tests/battle-members.php b/tests/battle-members.php index 84d36c5..c475e2f 100644 --- a/tests/battle-members.php +++ b/tests/battle-members.php @@ -71,6 +71,7 @@ function runBattleMembers(string $project, array $members, array $options = []): } $project = writeBattleTestProject(); +writeBattleLoadoutSources($project); try { [$command, $status] = runBattleMembers($project, []); @@ -101,6 +102,32 @@ function runBattleMembers(string $project, array $members, array $options = []): 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']); diff --git a/tests/fixtures/battle-project.php b/tests/fixtures/battle-project.php index 47ae551..6303efa 100644 --- a/tests/fixtures/battle-project.php +++ b/tests/fixtures/battle-project.php @@ -39,6 +39,32 @@ function writeBattleTestProject(): string return $root; } +/** + * Gives the project a battle test loadout to grant: an ability and a spell, + * and a story-gated summon only Hero may hold. + */ +function writeBattleLoadoutSources(string $root): void +{ + file_put_contents($root . '/assets/Data/abilities.php', <<<'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), From 13491a8301dbf086958f5145f56907eeebe97b19 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 12:37:17 +0200 Subject: [PATCH 19/32] feat(edit): open a project in the GUI editor with edit --gui `ichiloto edit --gui` launches the native editor window with this console's hidden `edit:host` as the session host it edits through (the Editor's SessionHost over standard input and output). The terminal editor is unchanged and remains the default. - GuiEditorLocator finds the executable without building or downloading it: ICHILOTO_GUI_EDITOR, else a release then a debug build in the gui-editor checkout beside the console; otherwise it says how to build it. - EditorProjectBootstrap owns preparing a process to edit a project (the engine and the project's PSR-4 classes); EditCommand's private bootstrap helpers moved there so both interfaces prepare it one way. --- bin/ichiloto | 2 + composer.json | 1 + src/Commands/EditCommand.php | 75 +++++++++----------------- src/Commands/EditHostCommand.php | 47 ++++++++++++++++ src/Support/EditorProjectBootstrap.php | 59 ++++++++++++++++++++ src/Support/GuiEditorLocator.php | 71 ++++++++++++++++++++++++ tests/gui-editor-launch.php | 54 +++++++++++++++++++ 7 files changed, 260 insertions(+), 49 deletions(-) create mode 100644 src/Commands/EditHostCommand.php create mode 100644 src/Support/EditorProjectBootstrap.php create mode 100644 src/Support/GuiEditorLocator.php create mode 100644 tests/gui-editor-launch.php diff --git a/bin/ichiloto b/bin/ichiloto index 6b86e84..22a2f63 100755 --- a/bin/ichiloto +++ b/bin/ichiloto @@ -14,6 +14,7 @@ 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; @@ -37,6 +38,7 @@ function bootstrap(): int new PlayCommand(), new BattleCommand(), new EditCommand(), + new EditHostCommand(), new GenerateMapCommand(), new GenerateActorCommand(), new GenerateFigletCommand(), diff --git a/composer.json b/composer.json index 17cc1b3..e9c4fd4 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "@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", 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/Support/EditorProjectBootstrap.php b/src/Support/EditorProjectBootstrap.php new file mode 100644 index 0000000..45fb83f --- /dev/null +++ b/src/Support/EditorProjectBootstrap.php @@ -0,0 +1,59 @@ + $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/GuiEditorLocator.php b/src/Support/GuiEditorLocator.php new file mode 100644 index 0000000..54db2f2 --- /dev/null +++ b/src/Support/GuiEditorLocator.php @@ -0,0 +1,71 @@ +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()[0], 3), + self::ENVIRONMENT, + implode(', ', $this->getCandidates()), + ); + } + + /** @return list */ + private function getCandidates(): array + { + $checkout = dirname(rtrim($this->consoleRoot, DIRECTORY_SEPARATOR)) . DIRECTORY_SEPARATOR . 'gui-editor'; + + return [ + $checkout . '/target/release/' . self::BINARY, + $checkout . '/target/debug/' . self::BINARY, + ]; + } +} diff --git a/tests/gui-editor-launch.php b/tests/gui-editor-launch.php new file mode 100644 index 0000000..4538c4b --- /dev/null +++ b/tests/gui-editor-launch.php @@ -0,0 +1,54 @@ +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'); +} finally { + foreach ([$release, $debug] as $file) { + @unlink($file); + } + exec('rm -rf ' . escapeshellarg($workspace)); +} + +echo "gui-editor-launch: ok\n"; From caf581264d9234ea8886ffd8c316c4a3598d6e72 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 3 Oct 2026 14:56:20 +0200 Subject: [PATCH 20/32] feat(edit): tell the editor which console opened it, for playtests EditorProjectBootstrap sets ICHILOTO_CONSOLE_BIN to this console unless the author named one, so both the terminal and the graphical editor play test through the console that opened them rather than another found in the project or on the PATH. --- src/Support/EditorProjectBootstrap.php | 22 ++++++++++++++++++++++ tests/gui-editor-launch.php | 11 +++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/Support/EditorProjectBootstrap.php b/src/Support/EditorProjectBootstrap.php index 45fb83f..61fa547 100644 --- a/src/Support/EditorProjectBootstrap.php +++ b/src/Support/EditorProjectBootstrap.php @@ -11,11 +11,33 @@ */ final class EditorProjectBootstrap { + /** The variable the editor's playtest reads to find the console that plays the game. */ + public const string CONSOLE_BINARY_ENVIRONMENT = 'ICHILOTO_CONSOLE_BIN'; + /** Loads the engine and registers the project's classes. */ public static function prepare(string $projectDirectory): void { load_engine_autoloader($projectDirectory); self::registerProjectAutoload($projectDirectory); + self::nameConsoleBinary(); + } + + /** + * Tells the editor which console opened it, so its playtest plays with + * the same console rather than another one found on the project or the + * PATH. An author who names one keeps it. + */ + public static function nameConsoleBinary(): void + { + if (getenv(self::CONSOLE_BINARY_ENVIRONMENT) === false) { + putenv(self::CONSOLE_BINARY_ENVIRONMENT . '=' . self::getConsoleBinary()); + } + } + + /** This console's entry point. */ + public static function getConsoleBinary(): string + { + return dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'ichiloto'; } /** Registers the opened project's PSR-4 autoload rules for editor asset inspection. */ diff --git a/tests/gui-editor-launch.php b/tests/gui-editor-launch.php index 4538c4b..b9db595 100644 --- a/tests/gui-editor-launch.php +++ b/tests/gui-editor-launch.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Ichiloto\Console\Support\EditorProjectBootstrap; use Ichiloto\Console\Support\GuiEditorLocator; require dirname(__DIR__) . '/vendor/autoload.php'; @@ -51,4 +52,14 @@ function assertGuiLaunch(bool $condition, string $message): void 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"; From c04631dd3e12d31b1cced0923d7263228faefd47 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 10:51:25 +0200 Subject: [PATCH 21/32] feat(new): scaffold enemies.php as the loader of Enemies/ records New projects author enemies one record per file in assets/Data/Enemies, so enemies.php returns what EnemyCatalog loads from there instead of an empty list that would never include them. --- src/Support/NewProjectScaffolder.php | 10 +++++++++- tests/new-project-save-compatibility.php | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Support/NewProjectScaffolder.php b/src/Support/NewProjectScaffolder.php index f09034d..0fd68de 100644 --- a/src/Support/NewProjectScaffolder.php +++ b/src/Support/NewProjectScaffolder.php @@ -72,7 +72,7 @@ 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'), @@ -666,6 +666,14 @@ private function renderCollisionDictionary(): string /** * @param array|list $payload */ + /** Enemies are authored one record per file in Enemies/; this file loads them. */ + private function renderEnemiesBarrel(): string + { + return "exportPhpValue($payload) . ";\n"; diff --git a/tests/new-project-save-compatibility.php b/tests/new-project-save-compatibility.php index 751fc16..8a7b948 100644 --- a/tests/new-project-save-compatibility.php +++ b/tests/new-project-save-compatibility.php @@ -150,6 +150,27 @@ function validateScaffoldedProject(string $projectRoot, array $options = []): ar 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/.'); + } + $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.'); From a37dcb15aee3595e0438ed2daa1853e5b23b5817 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 16:37:18 +0200 Subject: [PATCH 22/32] feat(scaffold): author new projects' skills as records that skills.php loads A new project's skills.php is the barrel over assets/Data/Skills, as enemies.php is over Enemies; abilities.php and magic.php are no longer written. The battle test fixture authors its skills as records. --- src/Support/NewProjectScaffolder.php | 18 +++++++++--------- tests/fixtures/battle-project.php | 19 +++++++++---------- tests/new-project-save-compatibility.php | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/Support/NewProjectScaffolder.php b/src/Support/NewProjectScaffolder.php index 0fd68de..0142ebe 100644 --- a/src/Support/NewProjectScaffolder.php +++ b/src/Support/NewProjectScaffolder.php @@ -58,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([]), @@ -78,13 +74,9 @@ public function scaffold(array $blueprint): array Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'items.php'), $this->renderPhpArrayFile([]), ); - $files[] = $this->writeFile( - Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'magic.php'), - $this->renderPhpArrayFile([]), - ); $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'), @@ -666,6 +658,14 @@ private function renderCollisionDictionary(): string /** * @param array|list $payload */ + /** Skills are authored one record per numbered file in Skills/; this file loads them. */ + private function renderSkillsBarrel(): string + { + return " [$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', ' 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/.'); + } + $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.'); From 187797d7f6e3790b40d4e1eb58868e90a30fdff7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 17:45:09 +0200 Subject: [PATCH 23/32] feat(scaffold): author new projects' items, weapons and armors as records that items.php loads --- src/Support/NewProjectScaffolder.php | 20 ++++++++++++++++---- tests/fixtures/battle-project.php | 18 +++++++++--------- tests/new-project-save-compatibility.php | 16 ++++++++++++++++ 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/Support/NewProjectScaffolder.php b/src/Support/NewProjectScaffolder.php index 0142ebe..4eaa048 100644 --- a/src/Support/NewProjectScaffolder.php +++ b/src/Support/NewProjectScaffolder.php @@ -72,7 +72,7 @@ public function scaffold(array $blueprint): array ); $files[] = $this->writeFile( Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'items.php'), - $this->renderPhpArrayFile([]), + $this->renderItemsBarrel(), ); $files[] = $this->writeFile( Path::join($blueprint['targetDirectory'], 'assets', 'Data', 'skills.php'), @@ -655,9 +655,6 @@ private function renderCollisionDictionary(): string PHP; } - /** - * @param array|list $payload - */ /** Skills are authored one record per numbered file in Skills/; this file loads them. */ private function renderSkillsBarrel(): string { @@ -674,6 +671,21 @@ private function renderEnemiesBarrel(): string . "return EnemyCatalog::loadProjectEnemies(dirname(__DIR__));\n"; } + /** + * Items, weapons and armors are authored one record per numbered file in + * Items/, Weapons/ and Armors/; this file loads them. + */ + private function renderItemsBarrel(): string + { + return "|list $payload + */ private function renderPhpArrayFile(array $payload): string { return "exportPhpValue($payload) . ";\n"; diff --git a/tests/fixtures/battle-project.php b/tests/fixtures/battle-project.php index 8d84f80..6bfdd72 100644 --- a/tests/fixtures/battle-project.php +++ b/tests/fixtures/battle-project.php @@ -26,15 +26,15 @@ function writeBattleTestProject(): string ], ]], true) . ";\n"); } - file_put_contents($root . '/assets/Data/items.php', <<<'ITEMS' - ['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', " 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.'); From a2fb034607cb37c2900ac52dfb84f15bb8773822 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 21:17:02 +0200 Subject: [PATCH 24/32] feat(battle): choose the battle test arena with --arena The key is checked against the battle presentation's arenas, naming them when it is unknown. --runs and the terminal renderer refuse an arena they would not draw. --- src/Commands/BattleCommand.php | 48 ++++++++++++++++++++++++++++++++++ tests/battle-members.php | 37 ++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/Commands/BattleCommand.php b/src/Commands/BattleCommand.php index b138c22..7ad46b4 100644 --- a/src/Commands/BattleCommand.php +++ b/src/Commands/BattleCommand.php @@ -13,6 +13,7 @@ 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; @@ -88,6 +89,8 @@ public function configure(): void ->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.'); @@ -124,6 +127,14 @@ public function execute(InputInterface $input, OutputInterface $output): int 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(); // The engine's asset loading is relative to the working directory, so the @@ -141,6 +152,9 @@ public function execute(InputInterface $input, OutputInterface $output): int $this->registerProjectStores(); // 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()); @@ -180,6 +194,12 @@ public function execute(InputInterface $input, OutputInterface $output): int 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); @@ -684,6 +704,34 @@ protected function runArena(string $projectName, string $troop, BattleTestSetup * @param list $members The --member values. * @throws InvalidArgumentException Naming every problem, before any battle starts. */ + /** + * 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); + } + protected function createSetup(array $members): BattleTestSetup { $actors = $this->getActorStore(); diff --git a/tests/battle-members.php b/tests/battle-members.php index c475e2f..f7427a0 100644 --- a/tests/battle-members.php +++ b/tests/battle-members.php @@ -133,6 +133,43 @@ function runBattleMembers(string $project, array $members, array $options = []): [, $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'); + } } finally { removeBattleTestProject($project); } From c13bf829b85af58c9fe3e2263011faed1d175ba7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 4 Oct 2026 21:30:53 +0200 Subject: [PATCH 25/32] feat(edit): run the GUI editor from its macOS bundle, named Ichiloto On macOS the locator prefers gui-editor/build/macos/Ichiloto.app, whose executable links to the release build, before the bare release and debug builds. --- src/Support/GuiEditorLocator.php | 16 +++++++++++++--- tests/gui-editor-launch.php | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/Support/GuiEditorLocator.php b/src/Support/GuiEditorLocator.php index 54db2f2..57733de 100644 --- a/src/Support/GuiEditorLocator.php +++ b/src/Support/GuiEditorLocator.php @@ -7,14 +7,23 @@ /** * Finds the native GUI editor executable. The console never builds or * downloads it: it uses `ICHILOTO_GUI_EDITOR` when set, otherwise a build in - * the `gui-editor` checkout beside the console, release before debug. + * the `gui-editor` checkout beside the console, release before debug. On + * macOS the checkout's application bundle comes first, which runs the release + * build under the name Ichiloto rather than the executable's file name + * (`gui-editor/scripts/install-macos-app.php`). */ final readonly class GuiEditorLocator { public const string ENVIRONMENT = 'ICHILOTO_GUI_EDITOR'; public const string BINARY = 'ichiloto-gui-editor'; - public function __construct(private string $consoleRoot, private ?string $configured = null) + /** The macOS application bundle's executable, relative to the gui-editor checkout. */ + public const string MACOS_BUNDLE_EXECUTABLE = 'build/macos/Ichiloto.app/Contents/MacOS/' . self::BINARY; + + /** + * @param string|null $platform The operating system family; the one the console runs on when null. + */ + public function __construct(private string $consoleRoot, private ?string $configured = null, private ?string $platform = null) { } @@ -52,7 +61,7 @@ public function describeMissing(): string 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()[0], 3), + dirname($this->getCandidates()[array_key_last($this->getCandidates())], 3), self::ENVIRONMENT, implode(', ', $this->getCandidates()), ); @@ -64,6 +73,7 @@ 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/tests/gui-editor-launch.php b/tests/gui-editor-launch.php index b9db595..5bbd7e0 100644 --- a/tests/gui-editor-launch.php +++ b/tests/gui-editor-launch.php @@ -10,7 +10,8 @@ /** * `ichiloto edit --gui` finds the native editor without building or * downloading it: the executable ICHILOTO_GUI_EDITOR names, else a release - * then a debug build in the gui-editor checkout beside the console. + * then a debug build in the gui-editor checkout beside the console, and on + * macOS first the checkout's Ichiloto.app around the release build. */ function assertGuiLaunch(bool $condition, string $message): void { @@ -29,7 +30,7 @@ function assertGuiLaunch(bool $condition, string $message): void mkdir(dirname($debug), 0777, true); try { - $locator = new GuiEditorLocator($console); + $locator = new GuiEditorLocator($console, platform: 'Linux'); assertGuiLaunch($locator->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'); @@ -45,6 +46,15 @@ function assertGuiLaunch(bool $condition, string $message): void 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); From 3771d0e2543d37ff77d6fb13be28efb1035b88ea Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 11:37:25 +0200 Subject: [PATCH 26/32] docs(battle): show a current summon in the --member examples Last Legend's Ifrit is now Djin, so the help and README examples name djin. The help example's member is Kaelion, since Liora never summons. --- README.md | 2 +- src/Battle/BattleMemberOption.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8664a90..0fca0ad 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ 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 --member "Kaelion:20,Commands=attack|skill|summon|item,Summons=ifrit" --member "Liora:20,Skills=Burn I|Heal I" --troop "Loch Ness" +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 diff --git a/src/Battle/BattleMemberOption.php b/src/Battle/BattleMemberOption.php index 8fdda35..13839f0 100644 --- a/src/Battle/BattleMemberOption.php +++ b/src/Battle/BattleMemberOption.php @@ -9,7 +9,7 @@ /** * One --member of ichiloto battle, as typed: * `Actor[:level][,Slot=item...][,Commands=a|b][,Skills=a|b][,Summons=a|b]`, - * for example `Liora:20,Weapon=Wooden Staff,Commands=attack|magic|summon,Skills=Burn I|Heal I,Summons=ifrit`. + * for example `Kaelion:20,Weapon=Wooden Sword,Commands=attack|magic|summon,Skills=Burn I|Heal I,Summons=djin`. * * The actor, items, commands, skills and summons are references for the * project to resolve. A slot given no item is empty. Commands replaces the @@ -87,7 +87,7 @@ private static function parseList(string $reference, string $key, string $value) $value, $key, $key, - match (strtolower($key)) { 'commands' => 'attack|magic|summon', 'skills' => 'Burn I|Heal I', default => 'ifrit' }, + 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)); From 281834527a3dff2517f4c2d767fa840f7d3c7265 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Mon, 5 Oct 2026 16:31:42 +0200 Subject: [PATCH 27/32] feat(battle): start from the project's battle test party and arena Without --member, ichiloto battle sets up the battle test its project keeps in system data (the Engine's ProjectBattleTest: its party, else the starting party, and its arena). --member replaces only the party and --arena only the arena. A malformed project battle test is refused with its problems. Engines without ProjectBattleTest keep the starting party. --- src/Commands/BattleCommand.php | 25 +++++++++++++++---------- tests/battle-members.php | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/Commands/BattleCommand.php b/src/Commands/BattleCommand.php index 7ad46b4..ccc571e 100644 --- a/src/Commands/BattleCommand.php +++ b/src/Commands/BattleCommand.php @@ -23,6 +23,7 @@ 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; @@ -696,14 +697,6 @@ protected function runArena(string $projectName, string $troop, BattleTestSetup ])->run(); } - /** - * The battle test setup the --member options describe, or the starting - * party without them. 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. - */ /** * The setup fighting in one of the battle presentation's arenas, named by * key. @@ -732,12 +725,24 @@ protected function chooseArena(BattleTestSetup $setup, string $arena): BattleTes 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 BattleTestSetup::getFromStartingParty($actors); + 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.', @@ -785,7 +790,7 @@ protected function createSetup(array $members): BattleTestSetup $setupMembers[] = new BattleTestMember($actorId, $level, $equipment, $commands, $skills, $summons); } } - $setup = $setupMembers === [] ? null : new BattleTestSetup($setupMembers); + $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)); diff --git a/tests/battle-members.php b/tests/battle-members.php index f7427a0..6f6f967 100644 --- a/tests/battle-members.php +++ b/tests/battle-members.php @@ -169,6 +169,33 @@ function runBattleMembers(string $project, array $members, array $options = []): [, $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); From 8de5de4786ab7ab897b0a60495a5a3b71d4c2141 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 8 Oct 2026 11:02:13 +0200 Subject: [PATCH 28/32] fix(governance): remove direct-main publishing allowances --- .githooks/pre-commit | 22 +++++ .githooks/pre-merge-commit | 22 +++++ .githooks/pre-push | 31 ++++++ .githooks/test-guards.sh | 140 +++++++++++++++++++++++++++ .github/copilot-instructions.md | 4 + .github/pull_request_template.md | 11 +++ .github/workflows/git-governance.yml | 35 +++++++ AGENTS.md | 13 +++ CLAUDE.md | 6 ++ GIT_WORKFLOW.md | 103 ++++++++++++++++++++ README.md | 6 ++ scripts/install-git-guards.sh | 33 +++++++ 12 files changed, 426 insertions(+) create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-merge-commit create mode 100755 .githooks/pre-push create mode 100755 .githooks/test-guards.sh create mode 100644 .github/copilot-instructions.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/git-governance.yml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 GIT_WORKFLOW.md create mode 100755 scripts/install-git-guards.sh 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..c6392c0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +Read `AGENTS.md` and `GIT_WORKFLOW.md` before acting. Andrew prohibits direct +commits, local merges and pushes to `main`. All changes integrate into +`develop` and reach `main` only through a same-repository develop-to-main PR. +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..086f783 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## 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. +- [ ] A PR into `main` comes from this repository's `develop`. +- [ ] Any working branch feeds `develop`; its publication has explicit approval. +- [ ] No direct-main commit/push, unauthorized branch deletion or history rewrite. +- [ ] 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..88c58d0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# 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`. 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`. +Remote working branches (including a missing `develop`), history rewrites and +branch deletion require explicit scoped authorization. Prior direct-main +exception claims are superseded by Andrew's absolute 2026-10-08 rule. +Install the tracked Git guards before committing or publishing. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0c71f54 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +# Claude instructions + +Read `AGENTS.md` and `GIT_WORKFLOW.md` in full before work, after compaction +and before publishing. They contain Andrew's binding Git workflow. +No direct-main commits or pushes; all main changes use a same-repository +`develop` -> `main` PR. Never infer an exception from historical messages. diff --git a/GIT_WORKFLOW.md b/GIT_WORKFLOW.md new file mode 100644 index 0000000..01177c8 --- /dev/null +++ b/GIT_WORKFLOW.md @@ -0,0 +1,103 @@ +# Ichiloto Git workflow + +Andrew's binding rule applies to every Ichiloto repository, including private +story documentation, agent coordination, organization templates and websites: +**NEVER commit directly to `main`. All changes integrate into `develop`. +`main` changes ONLY through a GitHub pull request from the same repository's +`develop`.** There is no documentation, private-repository or minor-change exception. + +## 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. +The 2026-10-08 absolute rule supersedes previous claims allowing private-docs +or `ichiloto/docs` direct-main publication. Preserve those records as history. + +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 public repositories 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 + +The portable policy and guard kit are maintained together in `ichiloto/.github` +and mirrored identically in active repositories so standalone clones retain +Andrew's rules. Governance changes must audit and update all mirrors through +`develop` and develop-to-main PRs. GitHub does not automatically inherit agent +instructions, Git hooks or workflows from the organization `.github` repository. +Do not assume a parent-directory policy exists in another checkout. + +## 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 6f32dfd..baad01a 100644 --- a/README.md +++ b/README.md @@ -339,3 +339,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/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" From 421ea105694e1ad2ec57e54c104b027bf49bf3b7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 8 Oct 2026 11:17:22 +0200 Subject: [PATCH 29/32] docs(governance): remove dependency on excluded shared repository --- GIT_WORKFLOW.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/GIT_WORKFLOW.md b/GIT_WORKFLOW.md index 01177c8..2e2869c 100644 --- a/GIT_WORKFLOW.md +++ b/GIT_WORKFLOW.md @@ -81,12 +81,16 @@ implicitly authorized. ## Shared policy maintenance -The portable policy and guard kit are maintained together in `ichiloto/.github` -and mirrored identically in active repositories so standalone clones retain -Andrew's rules. Governance changes must audit and update all mirrors through -`develop` and develop-to-main PRs. GitHub does not automatically inherit agent -instructions, Git hooks or workflows from the organization `.github` repository. -Do not assume a parent-directory policy exists in another checkout. +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. Repositories without remote `develop` +are excluded from this remediation at Andrew's direction; do not create that +branch or treat their default branch as an integration substitute. 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 From e5f71fc208da4ecce32c5f4578d125f3b38dfe52 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 8 Oct 2026 11:24:08 +0200 Subject: [PATCH 30/32] docs: remove universal workflow scope beyond Andrew-created develop Remove application of the develop-to-main restriction to repositories without an Andrew-created develop branch. Leave those repositories outside this remediation and do not infer authority to create develop or publish main. Preserve existing-develop integration, explicit branch-publication permissions, branch preservation, history safeguards, and current repository guards. --- .github/copilot-instructions.md | 11 ++++++++--- .github/pull_request_template.md | 7 ++++--- AGENTS.md | 18 +++++++++++------- CLAUDE.md | 11 ++++++++--- GIT_WORKFLOW.md | 30 ++++++++++++++++++++---------- 5 files changed, 51 insertions(+), 26 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c6392c0..8555c0e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,9 @@ -Read `AGENTS.md` and `GIT_WORKFLOW.md` before acting. Andrew prohibits direct -commits, local merges and pushes to `main`. All changes integrate into -`develop` and reach `main` only through a same-repository develop-to-main PR. +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 index 086f783..48bdc1b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,7 +5,8 @@ Describe the resulting behavior, tests run, and relevant limits. ## Git workflow - [ ] I read `AGENTS.md` and `GIT_WORKFLOW.md` and preserved existing work. -- [ ] A PR into `main` comes from this repository's `develop`. -- [ ] Any working branch feeds `develop`; its publication has explicit approval. -- [ ] No direct-main commit/push, unauthorized branch deletion or history rewrite. +- [ ] 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/AGENTS.md b/AGENTS.md index 88c58d0..6d7974f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,14 @@ 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`. 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`. -Remote working branches (including a missing `develop`), history rewrites and -branch deletion require explicit scoped authorization. Prior direct-main -exception claims are superseded by Andrew's absolute 2026-10-08 rule. -Install the tracked Git guards before committing or publishing. +`/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 index 0c71f54..5aa59fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,11 @@ # Claude instructions Read `AGENTS.md` and `GIT_WORKFLOW.md` in full before work, after compaction -and before publishing. They contain Andrew's binding Git workflow. -No direct-main commits or pushes; all main changes use a same-repository -`develop` -> `main` PR. Never infer an exception from historical messages. +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 index 2e2869c..bc322b4 100644 --- a/GIT_WORKFLOW.md +++ b/GIT_WORKFLOW.md @@ -1,10 +1,16 @@ # Ichiloto Git workflow -Andrew's binding rule applies to every Ichiloto repository, including private -story documentation, agent coordination, organization templates and websites: +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`.** There is no documentation, private-repository or minor-change exception. +`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 @@ -13,8 +19,9 @@ 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. -The 2026-10-08 absolute rule supersedes previous claims allowing private-docs -or `ichiloto/docs` direct-main publication. Preserve those records as history. +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 @@ -66,8 +73,9 @@ 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 public repositories on the current Free organization plan, `main` has a -separate update restriction with PR-only merger bypass, plus a no-bypass ruleset +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 @@ -85,9 +93,11 @@ 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. Repositories without remote `develop` -are excluded from this remediation at Andrew's direction; do not create that -branch or treat their default branch as an integration substitute. GitHub does +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. From a73eb85db9384f22c95348e3fc70676158ea54b7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 10 Oct 2026 00:54:46 +0200 Subject: [PATCH 31/32] chore(governance): preserve existing develop publishing safeguards --- .githooks/pre-commit | 22 +++++ .githooks/pre-merge-commit | 22 +++++ .githooks/pre-push | 31 ++++++ .githooks/test-guards.sh | 140 +++++++++++++++++++++++++++ .github/copilot-instructions.md | 9 ++ .github/pull_request_template.md | 12 +++ .github/workflows/git-governance.yml | 35 +++++++ AGENTS.md | 17 ++++ CLAUDE.md | 11 +++ GIT_WORKFLOW.md | 117 ++++++++++++++++++++++ README.md | 6 ++ scripts/install-git-guards.sh | 33 +++++++ 12 files changed, 455 insertions(+) create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-merge-commit create mode 100755 .githooks/pre-push create mode 100755 .githooks/test-guards.sh create mode 100644 .github/copilot-instructions.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/git-governance.yml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 GIT_WORKFLOW.md create mode 100755 scripts/install-git-guards.sh 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 0fca0ad..093f499 100644 --- a/README.md +++ b/README.md @@ -373,3 +373,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/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" From c558aec3cd277a943f17a5b4006b589417b106cd Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 11 Oct 2026 05:19:58 +0200 Subject: [PATCH 32/32] feat(play): add explicit startup and owned terminal sessions Owned terminal-window mode removes crash-window holding and closes its owned window on every completion; diagnostics and child exit status remain available to the caller. Existing tmux crash preservation is unchanged. --- README.md | 43 +++ bin/ichiloto | 2 + composer.json | 3 + src/Commands/PlayCommand.php | 42 ++- src/Commands/RendererListCommand.php | 51 +++ src/Renderer/ProjectRendererAvailability.php | 131 ++++++++ src/Renderer/RendererSelector.php | 17 + src/Support/GameLaunchCommandBuilder.php | 32 +- src/Support/GameProcessLauncher.php | 9 +- src/Support/ProjectStartupCheck.php | 33 ++ src/Support/TerminalSessionChannel.php | 76 +++++ src/Support/TerminalSessionLauncher.php | 252 ++++++++++++++ src/Support/TerminalSessionOwner.php | 51 +++ src/Support/TerminalSessionProcess.php | 222 +++++++++++++ src/Support/TerminalSessionScratch.php | 60 ++++ src/Support/TerminalSessionWindow.php | 166 ++++++++++ src/Support/TerminalSessionWorker.php | 154 +++++++++ tests/play-startup.php | 165 ++++++++++ tests/renderer-availability.php | 227 +++++++++++++ tests/terminal-session.php | 326 +++++++++++++++++++ 20 files changed, 2049 insertions(+), 13 deletions(-) create mode 100644 src/Commands/RendererListCommand.php create mode 100644 src/Renderer/ProjectRendererAvailability.php create mode 100644 src/Support/ProjectStartupCheck.php create mode 100644 src/Support/TerminalSessionChannel.php create mode 100644 src/Support/TerminalSessionLauncher.php create mode 100644 src/Support/TerminalSessionOwner.php create mode 100644 src/Support/TerminalSessionProcess.php create mode 100644 src/Support/TerminalSessionScratch.php create mode 100644 src/Support/TerminalSessionWindow.php create mode 100644 src/Support/TerminalSessionWorker.php create mode 100644 tests/play-startup.php create mode 100644 tests/renderer-availability.php create mode 100644 tests/terminal-session.php diff --git a/README.md b/README.md index 093f499..51f5680 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,49 @@ an executable location. 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; use a save-less disposable project overlay 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. + +`--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. diff --git a/bin/ichiloto b/bin/ichiloto index 22a2f63..82c169d 100755 --- a/bin/ichiloto +++ b/bin/ichiloto @@ -21,6 +21,7 @@ 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; @@ -43,6 +44,7 @@ function bootstrap(): int new GenerateActorCommand(), new GenerateFigletCommand(), new RendererInstallCommand(), + new RendererListCommand(), new RendererUpdateCommand(), new UpgradeCommand(), new ValidateCommand() diff --git a/composer.json b/composer.json index e9c4fd4..c724f57 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,9 @@ "@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/terminal-session.php", "@php tests/renderer-install.php", "@php tests/renderer-preparation.php", "@php tests/generate-map.php", diff --git a/src/Commands/PlayCommand.php b/src/Commands/PlayCommand.php index e155b76..01eaf1d 100644 --- a/src/Commands/PlayCommand.php +++ b/src/Commands/PlayCommand.php @@ -7,13 +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; @@ -50,6 +53,9 @@ public function __construct( ?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); @@ -69,11 +75,13 @@ 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('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.'); } @@ -137,20 +145,44 @@ 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.'); + } + 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); + if ((bool) $input->getOption('terminal-window') && $renderer->id === 'terminal') { + $environment = $this->launchCommandBuilder->buildEnvironment($renderer->id, $startMode); + $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, $input, $output); + return $this->launchInTmux($workingDirectory, $mainFile, $errorLogFile, $renderer->id, $input, $output, $startMode); } $this->rendererUpdateOffer->offer($workingDirectory, $renderer->id, $input, $output); @@ -160,6 +192,7 @@ public function execute(InputInterface $input, OutputInterface $output): int mainFile: $mainFile, errorLogFile: $errorLogFile, rendererId: $renderer->id, + startMode: $startMode, ); if ($resultCode === null) { @@ -214,10 +247,11 @@ private function launchInTmux( string $rendererId, InputInterface $input, OutputInterface $output, + ?string $startMode = 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); $launchCommand = $this->launchCommandBuilder->buildCrashPreservingCommand($gameCommand, 'Ichiloto game'); if (! $this->tmuxSessionExists($sessionName)) { 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/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/GameLaunchCommandBuilder.php b/src/Support/GameLaunchCommandBuilder.php index 588512b..56f8831 100644 --- a/src/Support/GameLaunchCommandBuilder.php +++ b/src/Support/GameLaunchCommandBuilder.php @@ -7,6 +7,28 @@ final class GameLaunchCommandBuilder { public const RENDERER_ENVIRONMENT_VARIABLE = 'ICHILOTO_RENDERER'; + public const STARTUP_ENVIRONMENT_VARIABLE = 'ICHILOTO_START_MODE'; + + /** @return array */ + public function buildEnvironment(string $rendererId, ?string $startMode = null): array + { + $environment = getenv(); + $environment = is_array($environment) ? $environment : []; + $environment[self::RENDERER_ENVIRONMENT_VARIABLE] = $rendererId; + if ($startMode !== null) { + $this->validateStartMode($startMode); + $environment[self::STARTUP_ENVIRONMENT_VARIABLE] = $startMode; + } + + return $environment; + } + + 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 +49,26 @@ 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 { $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; + } + + return $command; } public function buildCrashPreservingCommand(string $command, string $label): string diff --git a/src/Support/GameProcessLauncher.php b/src/Support/GameProcessLauncher.php index 8f13677..8a997ce 100644 --- a/src/Support/GameProcessLauncher.php +++ b/src/Support/GameProcessLauncher.php @@ -19,20 +19,15 @@ public function launch( string $mainFile, string $errorLogFile, string $rendererId, + ?string $startMode = null, ): ?int { + $environment = $this->commandBuilder->buildEnvironment($rendererId, $startMode); $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/ProjectStartupCheck.php b/src/Support/ProjectStartupCheck.php new file mode 100644 index 0000000..86ffa2f --- /dev/null +++ b/src/Support/ProjectStartupCheck.php @@ -0,0 +1,33 @@ +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/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..8d8abc1 --- /dev/null +++ b/src/Support/TerminalSessionWorker.php @@ -0,0 +1,154 @@ + $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; + 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); + } + $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(); + } + + // 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/tests/play-startup.php b/tests/play-startup.php new file mode 100644 index 0000000..8ea445a --- /dev/null +++ b/tests/play-startup.php @@ -0,0 +1,165 @@ +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 = dirname(__DIR__, 2) . '/engine/src/Core/GameStartupMode.php'; +$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/renderer-availability.php b/tests/renderer-availability.php new file mode 100644 index 0000000..d681799 --- /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, ' 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 local 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/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); +}